Merge branch 'task/2425-inapp-confirm' into feat/attachment-options-panel

This commit is contained in:
xarmian
2026-08-04 13:48:43 +00:00
9 changed files with 603 additions and 170 deletions
+19 -6
View File
@@ -209,14 +209,27 @@ test.describe('item attachment strip', () => {
const del = page.locator(DELETE_BTN).first();
await del.focus();
await expect(del).toBeFocused();
// WCAG 2.2 target size (2.5.8), from PLAN-2382. Only a real browser
// applies the scoped CSS, so this is the only place it can be checked.
const box = await del.boundingBox();
expect(box?.width).toBeGreaterThanOrEqual(24);
expect(box?.height).toBeGreaterThanOrEqual(24);
page.once('dialog', (dialog) => {
// The attachment IS embedded in the body (the drop inserted it), so
// the confirm must say so rather than hedging.
expect(dialog.message()).toContain("still used in this item's content");
void dialog.accept();
});
// TASK-2425 / DR-18: the confirmation is the app's own drill-down, not
// a browser dialog. A native `confirm()` would hang this click until
// Playwright auto-dismissed it, so the absence of a dialog handler is
// itself part of the assertion.
await del.click();
const confirmMenu = page.locator('[role="menu"]');
await expect(confirmMenu).toBeVisible();
// The attachment IS embedded in the body (the drop inserted it), so the
// prompt must say so rather than hedging.
await expect(confirmMenu.locator('.attachment-delete-prompt')).toContainText(
"still used in this item's content"
);
// Cancel is first, so the focus handoff can never land Enter on Delete.
await expect(confirmMenu.getByRole('menuitem').first()).toContainText('Cancel');
await confirmMenu.getByRole('menuitem', { name: 'Delete file' }).click();
await expect(page.locator(TILE)).toHaveCount(0);
// ...and the strip disappears entirely once empty.
+5 -4
View File
@@ -70,10 +70,11 @@ export interface AttachmentActionContext {
*/
origin?: string;
/**
* Confirmation gate for Delete. The surface owns the wording and the
* modality (the strip uses `window.confirm`), so a descriptor never
* invents one — but when supplied, returning false aborts before any
* request is sent.
* Confirmation gate for Delete. The surface owns the modality, so a
* descriptor never invents one — but when supplied, returning false aborts
* before any request is sent. Every surface now resolves this through the
* shared `AttachmentDeleteConfirm` drill-down (DR-18); the native
* `window.confirm` the strip used to raise is gone.
*/
confirmDelete?: (attachment: AttachmentActionSubject) => boolean | Promise<boolean>;
/** Called after the server confirms the row is gone (204 or 404). */
@@ -0,0 +1,100 @@
<!--
AttachmentDeleteConfirm — the ONE delete confirmation for an attachment
(PLAN-2392 DR-18, TASK-2425).
One object must not have two confirmation styles. The options panel drilled
down to an in-app sub-view while the strip's hover `×` still raised a
browser-native `window.confirm` — different chrome, different focus
behaviour, different copy shape, for the same DELETE. This component is what
both surfaces render instead, so the shape can only ever be changed in one
place.
THE SHAPE, copied from the item menu's confirm (ItemDetail, PLAN-2326 DR-6):
- The prompt is `role="presentation"`. A `role="menu"` owns menuitem /
separator / group children, and this says explicitly that the prompt is
none of them — but presentational text is then never announced on its
own, hence...
- ...the `aria-describedby` back-reference from the destructive row, which
is how a screen-reader user hears WHY they are being asked.
- Cancel comes FIRST. The menu's focus handoff lands on the first row, so
any other order would put Enter-on-arrival on Delete.
- The destructive row comes LAST, `danger`-styled.
It renders rows only — no `role="menu"` container of its own. Each caller
supplies its own `Menu` (the panel drills down inside the one it already
has; the strip opens one anchored to the tile's `×`), so ESC ordering,
outside-click, portal placement, focus return and the mobile sheet swap are
the app's existing behaviours on both surfaces rather than a second
implementation.
The PROMPT TEXT is `attachmentDeletePrompt` below, shared for the same
reason the markup is: the hedged arm's honesty is the substance of DR-5, and
two copies of it drift.
-->
<script lang="ts" module>
/**
* The two arms of the delete warning — the only place either is written.
*
* `referencedHere` can only ever speak for the body the caller has. So the
* "not referenced" arm deliberately does NOT claim the attachment is
* unused: a reference can live in another item's content, in an item's
* fields JSON, or in any comment. The server's `AttachmentReferenced` scan
* covers all three and none of it is visible client-side, so the wording
* stays hedged (DR-5). Do not "tighten" it.
*/
export function attachmentDeletePrompt(
displayName: string,
referencedHere: boolean
): string {
return referencedHere
? `Delete ${displayName}? It's still used in this item's content — deleting it will leave a "missing attachment" placeholder where it appears.`
: `Delete ${displayName}? It isn't referenced in this item's content, but it may still be referenced by another item or a comment. This cannot be undone.`;
}
</script>
<script lang="ts">
import MenuItem from '$lib/components/common/MenuItem.svelte';
interface Props {
/** The warning line — build it with `attachmentDeletePrompt`. */
prompt: string;
/**
* Unique id for the prompt element. Owned by the caller because it is
* the caller that may have several of these on a page; the destructive
* row's `aria-describedby` points at it.
*/
promptId: string;
oncancel: () => void;
onconfirm: () => void;
}
let { prompt, promptId, oncancel, onconfirm }: Props = $props();
</script>
<div class="attachment-delete-prompt" role="presentation" id={promptId}>{prompt}</div>
<MenuItem icon="‹" onclick={oncancel}>Cancel</MenuItem>
<div class="attachment-delete-divider" role="separator"></div>
<MenuItem icon="🗑" danger describedBy={promptId} onclick={onconfirm}>Delete file</MenuItem>
<style>
/* Logical properties throughout: the prompt carries a filename, which may
be 200 characters or right-to-left. */
.attachment-delete-prompt {
padding-block: 4px 6px;
padding-inline: 9px;
font-size: 12px;
line-height: 1.35;
font-weight: 500;
color: var(--accent-orange);
/* Wraps rather than ellipsizes: the prompt carries the filename and has
to stay readable in full. */
overflow-wrap: anywhere;
}
.attachment-delete-divider {
border-block-start: 1px solid var(--border-subtle);
margin-block: 5px;
margin-inline: 4px;
}
</style>
@@ -39,15 +39,14 @@
refetching — a plain refetch would replay the cached
failure and look broken (DR-10).
THE DELETE CONFIRMATION IS AN IN-APP DRILL-DOWN (DR-18), copying the item
menu's shape exactly: the prompt is `role="presentation"` with an
`aria-describedby` back-reference from the destructive row, Cancel comes
FIRST so the focus handoff can never land Enter on Delete, and the
contextual "still used in this item's content" warning is carried through
from the strip. It is wired as the descriptor's `confirmDelete` promise
rather than as a bespoke delete path, so the descriptor's own
identity-snapshot and permission re-check across the confirmation stay in
force.
THE DELETE CONFIRMATION IS AN IN-APP DRILL-DOWN (DR-18) — and it is the
SAME one the strip's tile shows, `AttachmentDeleteConfirm`, rows and prompt
text both. This panel supplies the sub-view slot; the confirmation owns the
shape (prompt as `role="presentation"`, `aria-describedby` back-reference,
Cancel first, destructive row last) and the two warning arms. It is wired as
the descriptor's `confirmDelete` promise rather than as a bespoke delete
path, so the descriptor's own identity-snapshot and permission re-check
across the confirmation stay in force.
SWITCH-SAFETY. The host swaps this component's props from one attachment
to another without a `{#key}` remount (a second tap while the panel is
@@ -65,6 +64,9 @@
import Menu from '$lib/components/common/Menu.svelte';
import MenuItem from '$lib/components/common/MenuItem.svelte';
import AttachmentIcon from '$lib/attachments/icons/AttachmentIcon.svelte';
import AttachmentDeleteConfirm, {
attachmentDeletePrompt,
} from './AttachmentDeleteConfirm.svelte';
import {
attachmentActionsFor,
type AttachmentActionContext,
@@ -382,20 +384,16 @@
* it resolves, which is the whole reason it is wired this way rather than
* as a bespoke "confirm, then call the API" path here.
*
* The "not referenced here" arm deliberately does NOT claim the attachment
* is unused, and stays hedged word-for-word with the strip's. Two
* independent reasons: a reference can live in another item's content, in
* fields JSON, or in any comment — the server's scan covers all three and
* none of it is visible client-side — and the body this checks is the
* The wording comes from the shared `attachmentDeletePrompt` — the same
* two arms the strip's tile shows (DR-18). The hedged arm matters here for
* one EXTRA reason beyond the shared one: the body this checks is the
* HOST's, which is not necessarily the attachment's parent item. The
* open-panel event's `itemId` is ROUTING, not ownership: a chip in a reused
* comment composer's unsubmitted draft correctly routes to the host in
* front of the user even after an item switch.
*/
function confirmDelete(): Promise<boolean> {
deletePrompt = referencedHere()
? `Delete ${displayName}? It's still used in this item's content — deleting it will leave a "missing attachment" placeholder where it appears.`
: `Delete ${displayName}? It isn't referenced in this item's content, but it may still be referenced by another item or a comment. This cannot be undone.`;
deletePrompt = attachmentDeletePrompt(displayName, referencedHere());
return new Promise<boolean>((resolve) => {
// Supersede any confirmation already up — two open at once would
// leave one resolver dangling forever.
@@ -565,18 +563,16 @@
{/each}
{:else}
<!--
Delete confirmation as a drill-down sub-view (DR-18), the same shape
as the item menu's: the prompt is presentational, so it is never
announced on its own — hence the aria-describedby back-reference
from the destructive row — and Cancel comes FIRST so the focusKey
handoff can never land Enter on Delete.
Delete confirmation as a drill-down sub-view (DR-18). The shape and
the wording live in the shared component, which the strip's tile
renders too — one confirmation for one object.
-->
<div class="ap-note ap-note-warn" role="presentation" id={promptId}>{deletePrompt}</div>
<MenuItem icon="‹" onclick={() => settleConfirm(false)}>Cancel</MenuItem>
<div class="menu-divider" role="separator"></div>
<MenuItem icon="🗑" danger describedBy={promptId} onclick={() => settleConfirm(true)}>
Delete file
</MenuItem>
<AttachmentDeleteConfirm
prompt={deletePrompt}
{promptId}
oncancel={() => settleConfirm(false)}
onconfirm={() => settleConfirm(true)}
/>
{/if}
</Menu>
@@ -636,16 +632,9 @@
padding-inline: 9px;
font-size: 12px;
line-height: 1.35;
/* Wraps rather than ellipsizes: the delete prompt carries the filename
and must stay readable in full. */
overflow-wrap: anywhere;
}
.ap-note-warn {
font-weight: 500;
color: var(--accent-orange);
}
.ap-note-error {
color: var(--accent-red);
}
@@ -350,7 +350,7 @@ describe('AttachmentPanelHost', () => {
await settle();
// The confirmation is a sub-view of the panel, not a window.confirm.
const prompt = document.querySelector('.ap-note-warn');
const prompt = document.querySelector('.attachment-delete-prompt');
expect(prompt?.getAttribute('role')).toBe('presentation');
expect(prompt?.textContent).toContain("still used in this item's content");
const confirmRows = rows();
@@ -381,7 +381,7 @@ describe('AttachmentPanelHost', () => {
row('Delete')!.click();
await settle();
expect(document.querySelector('.ap-note-warn')?.textContent).toContain(
expect(document.querySelector('.attachment-delete-prompt')?.textContent).toContain(
"isn't referenced in this item's content"
);
});
@@ -397,7 +397,7 @@ describe('AttachmentPanelHost', () => {
row('Delete')!.click();
await settle();
expect(document.querySelector('.ap-note-warn')?.textContent).toContain(
expect(document.querySelector('.attachment-delete-prompt')?.textContent).toContain(
"still used in this item's content"
);
});
@@ -32,9 +32,11 @@
* on screen must roll back and toast even if the user hit Retry while
* it was in flight.
* 3. `paint` — "does the CONTROL the user clicked belong to what is on
* screen?" Both control entry points fence on it — `handleDelete` for
* screen?" Both control entry points fence on it — `requestDelete` for
* a tile, `retryLoad` for the error row — at ENTRY, because the other
* two run after an await and no fence can unsend a request.
* two run after an await and no fence can unsend a request. The delete
* confirmation no longer blocks the thread (DR-18 / TASK-2425), so
* `confirmDelete` re-checks the same fence on the far side of it.
*/
import { onDestroy, untrack } from 'svelte';
import { api, PadApiError } from '$lib/api/client';
@@ -46,6 +48,10 @@
describeAttachmentType,
} from '$lib/attachments/display';
import AttachmentIcon from '$lib/attachments/icons/AttachmentIcon.svelte';
import Menu from '$lib/components/common/Menu.svelte';
import AttachmentDeleteConfirm, {
attachmentDeletePrompt,
} from '$lib/components/attachments/AttachmentDeleteConfirm.svelte';
import Lightbox, { type LightboxImage } from '$lib/components/common/Lightbox.svelte';
import { attachmentRefsIn } from '$lib/utils/commentAttachments';
import { invalidateAttachmentMetadata } from '$lib/components/editor/attachment-metadata';
@@ -158,6 +164,27 @@
let attachments = $state<StripAttachment[]>([]);
let expanded = $state(false);
let lightbox = $state<{ images: LightboxImage[]; index: number } | null>(null);
/**
* The delete confirmation currently on screen, if any (PLAN-2392 DR-18 /
* TASK-2425). One at a time: opening a second supersedes the first, which
* is what a `<Menu>` anchored to a single trigger can represent anyway.
*
* `anchor` is the tile's own `×` — the menu positions against it and
* returns focus to it on Cancel / Escape, which is what keeps the control
* keyboard-usable end to end.
*
* `prompt` is captured at OPEN time, not derived: the warning reads the
* editor's live markdown, and re-deriving it while the confirmation is up
* would let the message change under the user as they type.
*/
let pendingDelete = $state<{
att: StripAttachment;
anchor: HTMLElement | null;
prompt: string;
} | null>(null);
const uid = $props.id();
const promptId = `attachment-delete-note-${uid}`;
// Three distinguishable states, not two (DR-10). `loadFailed` is what stops
// a fetch failure from rendering as "no attachments"; `showLoading` is the
@@ -279,6 +306,12 @@
attachments = [];
expanded = false;
lightbox = null;
// A confirmation left up for a row the user is no longer looking
// at must go with it — confirming it after the switch would
// DELETE the previous view's attachment from behind the new one
// (the entry fence re-check in `confirmDelete` refuses it, but
// leaving the prompt on screen at all is the wrong picture).
pendingDelete = null;
deletedIds = new Set();
pendingUploads = [];
beyondStripCount = 0;
@@ -492,6 +525,10 @@
return registerAttachmentDeletionListener((deletedUuid) => {
rememberDeleted(deletedUuid);
attachments = attachments.filter((a) => a.id !== deletedUuid);
// The tile this confirmation is anchored to just went away, so the
// menu would be left pointing at a detached element — and the
// question it is asking has already been answered by someone else.
if (pendingDelete?.att.id === deletedUuid) pendingDelete = null;
});
});
@@ -630,7 +667,7 @@
/**
* A file tile opens the options panel instead of downloading (DR-1).
*
* ENTRY-fenced for the same reason `handleDelete` is: the clicked tile was
* ENTRY-fenced for the same reason `requestDelete` is: the clicked tile was
* painted for `paint`'s identity, while `itemId` is live and may already
* name a different view. `itemId` is the event's ROUTING field — which
* `ItemDetail` mount shows the panel — so a stale click would open this
@@ -676,30 +713,17 @@
}
/**
* Confirm text for a delete (DR-5).
* Open the delete confirmation for a tile (DR-18 / TASK-2425).
*
* The "not referenced here" arm deliberately does NOT claim the attachment
* is unused: a reference can live in another item's content, in an item's
* fields JSON, or in any comment. The server's AttachmentReferenced scan
* covers all three, but none of it is visible client-side — so the wording
* stays honest about what we actually checked.
* This used to raise a browser-native `window.confirm`, which meant one
* object had two confirmation styles — the options panel already drilled
* down to an in-app sub-view. Both surfaces now render the SAME
* `AttachmentDeleteConfirm`, wording included; only the container differs
* (the panel's own menu there, a menu anchored to the `×` here).
*
* The delete REQUEST path below is untouched by that change.
*/
function confirmMessage(att: StripAttachment): string {
if (referencedIds().has(att.id)) {
return (
`Delete ${att.filename}?\n\n` +
"It's still used in this item's content — deleting it will leave a " +
'"missing attachment" placeholder where it appears.'
);
}
return (
`Delete ${att.filename}?\n\n` +
"It isn't referenced in this item's content, but it may still be " +
'referenced by another item or a comment. This cannot be undone.'
);
}
async function handleDelete(att: StripAttachment) {
function requestDelete(att: StripAttachment, anchor: HTMLElement | null) {
if (!canDelete) return;
// ENTRY fence (fence 3 — see the header). The clicked tile was painted
@@ -713,10 +737,51 @@
// confirm and before the call.
if (!paint.isCurrent()) return;
if (typeof window !== 'undefined' && !window.confirm(confirmMessage(att))) return;
// window.confirm blocks the thread, so nothing can have moved between
// the fence above and here.
pendingDelete = {
att,
anchor,
prompt: attachmentDeletePrompt(att.filename, referencedIds().has(att.id)),
};
}
/** Dismissal that isn't an explicit Cancel — Escape, or a click outside. */
function dismissDelete() {
pendingDelete = null;
}
/**
* The Cancel row. Returns focus to the `×` the confirmation was anchored
* to: `window.confirm` restored it for free, and the control is
* opacity-hidden unless its cell has focus-within, so a keyboard user who
* cancels would otherwise be dropped on <body> with the control they came
* from now invisible.
*
* Deliberately NOT wired to `Menu`'s `onclose`: Escape already refocuses
* the trigger inside `Menu`, and an outside click must not have focus
* yanked back off whatever the user just clicked.
*/
function cancelDelete() {
const anchor = pendingDelete?.anchor;
pendingDelete = null;
anchor?.focus();
}
/**
* The user confirmed. `window.confirm` blocked the thread, so the entry
* fence taken when the `×` was clicked was still true by definition when it
* returned; an in-app confirmation does NOT block, and the user can switch
* item or workspace while it is up. So the fence — and the permission — are
* re-checked HERE, at the point that actually sends the request.
*/
function confirmDelete() {
const pending = pendingDelete;
pendingDelete = null;
if (!pending) return;
if (!canDelete || !paint.isCurrent()) return;
void performDelete(pending.att);
}
async function performDelete(att: StripAttachment) {
// Capture identity BEFORE the await (fence 2): a switch mid-delete must
// not roll the tile back into a DIFFERENT item's strip, and must not
// toast over it. The DELETE itself still lands — it targets an id, not a
@@ -885,7 +950,7 @@
class="att-delete"
title="Delete {att.filename}"
aria-label="Delete {att.filename}"
onclick={() => handleDelete(att)}
onclick={(e) => requestDelete(att, e.currentTarget)}
>
×
</button>
@@ -922,6 +987,43 @@
</section>
{/if}
<!--
The delete confirmation (DR-18). The same `Menu` presentation the options
panel uses — popover on desktop, BottomSheet at the mobile breakpoint — so
ESC ordering, outside-click, portal placement and focus return are the
app's existing behaviours rather than a second implementation. Focus
returns to the `×` the menu is anchored to, and Cancel is its first row, so
Enter on arrival can never delete.
-->
{#if pendingDelete}
<!--
Every prop below is read through `?.` even though the block only exists
while `pendingDelete` is set: `Menu` places itself in a `tick().then()`,
which can run AFTER the confirmation was dismissed and this block torn
down, and a prop expression is re-evaluated on every read. Reading
`pendingDelete.anchor` there throws an unhandled rejection — real, and
observed in the suite.
-->
<Menu
open
onclose={dismissDelete}
trigger={pendingDelete?.anchor ?? undefined}
mode="portal"
width={272}
sheetOnMobile
sheetTitle="Delete {pendingDelete?.att.filename ?? ''}"
ariaLabel="Delete {pendingDelete?.att.filename ?? ''}"
focusKey={pendingDelete?.att.id}
>
<AttachmentDeleteConfirm
prompt={pendingDelete?.prompt ?? ''}
{promptId}
oncancel={cancelDelete}
onconfirm={confirmDelete}
/>
</Menu>
{/if}
{#if lightbox}
<Lightbox
images={lightbox.images}
@@ -822,17 +822,63 @@ describe('ItemAttachmentStrip', () => {
});
// ── Delete (TASK-2384) ────────────────────────────────────────────────
// ── Delete (TASK-2384, confirmation reworked in TASK-2425) ────────────
//
// The affordance is gated on ItemDetail's `mutationsEnabled`
// (canEdit && !peeking) per PLAN-2382 DR-6, and the confirm text has to
// stay honest about what was actually checked (DR-5): "referenced in this
// item's content" is knowable client-side; "unused anywhere" is not.
//
// TASK-2425 (PLAN-2392 DR-18) replaced the browser-native `window.confirm`
// these tests used to spy on with the SAME in-app drill-down the options
// panel shows — so they now drive the real rows. That is not a cosmetic
// change to the tests: the native confirm blocked the thread, so nothing
// could move between the entry fence and the request, while the in-app one
// leaves a window in which the user can switch item or workspace. Every
// fence and rollback assertion below is preserved, and the confirmation is
// driven through the rows a user would actually click.
function deleteButtons(): HTMLButtonElement[] {
return Array.from(target.querySelectorAll<HTMLButtonElement>('.att-delete'));
}
/** Portaled to <body> like every other Menu, so queried document-wide. */
function confirmPanel(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="menu"]');
}
function confirmRows(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'));
}
/** By VISIBLE label — MenuItem's icon span is part of `textContent`. */
function confirmRow(label: string): HTMLElement | undefined {
return confirmRows().find(
(el) => el.querySelector('.mi-label')?.textContent?.trim() === label
);
}
function promptText(): string {
return document.querySelector('.attachment-delete-prompt')?.textContent ?? '';
}
/** Click a tile's `×`. Opens the confirmation; sends nothing. */
function openConfirm(index = 0) {
deleteButtons()[index].click();
flushSync();
}
/** The destructive row — the only thing that issues a DELETE. */
function clickConfirm() {
confirmRow('Delete file')!.click();
flushSync();
}
function clickCancel() {
confirmRow('Cancel')!.click();
flushSync();
}
it('offers no delete control when canDelete is false', async () => {
listMock.mockResolvedValue(response([att({ id: 'a1' })]));
props.canDelete = false;
@@ -863,6 +909,54 @@ describe('ItemAttachmentStrip', () => {
expect(buttons[0].disabled).toBe(false);
});
it('confirms in-app, never with a browser dialog, Cancel first (DR-18)', async () => {
// The shape the item menu establishes and the options panel already
// used: prompt as `role="presentation"` (a role="menu" owns only
// menuitem / separator / group children), an aria-describedby
// back-reference from the destructive row so the otherwise-unannounced
// prompt is read out, Cancel FIRST so the menu's focus handoff can
// never land Enter on Delete.
const nativeConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
listMock.mockResolvedValue(response([att({ id: 'a1' })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
openConfirm();
expect(nativeConfirm).not.toHaveBeenCalled();
const prompt = document.querySelector('.attachment-delete-prompt');
expect(prompt?.getAttribute('role')).toBe('presentation');
const rows = confirmRows();
const labelOf = (el: HTMLElement) => el.querySelector('.mi-label')?.textContent?.trim();
expect(labelOf(rows[0])).toBe('Cancel');
expect(labelOf(rows[rows.length - 1])).toBe('Delete file');
expect(rows[rows.length - 1].getAttribute('aria-describedby')).toBe(prompt?.id);
// Opening the confirmation is not a delete.
expect(deleteMock).not.toHaveBeenCalled();
nativeConfirm.mockRestore();
});
it('cancelling sends nothing, keeps the tile, and refocuses the × control', async () => {
listMock.mockResolvedValue(response([att({ id: 'a1' })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
const closeBtn = deleteButtons()[0];
openConfirm();
clickCancel();
await settle();
expect(deleteMock).not.toHaveBeenCalled();
expect(tiles()).toHaveLength(1);
expect(confirmPanel()).toBeNull();
// `window.confirm` restored focus for free. The control is also
// opacity-hidden unless its cell has focus-within, so dropping focus to
// <body> would make the affordance vanish under a keyboard user.
expect(document.activeElement).toBe(closeBtn);
});
it('warns that the attachment is still used in this item content', async () => {
// A canonical UUID: attachmentRefsIn() is anchored to that shape (the
// ids the upload endpoint returns), so the reference scan only matches
@@ -874,16 +968,14 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
deleteButtons()[0].click();
await settle();
openConfirm();
expect(confirmSpy).toHaveBeenCalledOnce();
expect(confirmSpy.mock.calls[0][0]).toContain("still used in this item's content");
expect(promptText()).toContain("still used in this item's content");
// Declined → nothing deleted, tile stays.
clickCancel();
await settle();
expect(deleteMock).not.toHaveBeenCalled();
expect(tiles()).toHaveLength(1);
confirmSpy.mockRestore();
});
it('never claims an unreferenced attachment is unused', async () => {
@@ -893,16 +985,13 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
deleteButtons()[0].click();
await settle();
openConfirm();
const message = String(confirmSpy.mock.calls[0][0]);
const message = promptText();
// Comment bodies and other items are NOT scanned client-side (DR-5),
// so the copy must hedge rather than assert non-use.
expect(message).toContain('may still be referenced');
expect(message).not.toContain('not used');
confirmSpy.mockRestore();
});
it('removes the tile optimistically and calls the API on confirm', async () => {
@@ -911,19 +1000,62 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
openConfirm();
clickConfirm();
await settle();
expect(deleteMock).toHaveBeenCalledWith('ws', 'a1');
expect(tiles()).toHaveLength(1);
expect(toastMock).not.toHaveBeenCalled();
// The confirmation goes with the row it was asking about.
expect(confirmPanel()).toBeNull();
// An <img> already painted in the editor never re-requests, so the
// NodeView has to be told or the body keeps showing a deleted image
// until reload (Codex round 12).
expect(notifyDeletedMock).toHaveBeenCalledWith('a1');
expect(invalidateMock).toHaveBeenCalledWith('ws', 'a1');
confirmSpy.mockRestore();
});
it('abandons an open confirmation when the item switches under it', async () => {
// The in-app confirmation does NOT block the thread the way
// `window.confirm` did, so this window exists at all only as of
// TASK-2425: the prompt can still be up when the strip repaints for a
// different item. Leaving it there would delete the PREVIOUS item's
// attachment from behind the new one.
listMock.mockResolvedValueOnce(response([att({ id: 'a1' })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
openConfirm();
expect(confirmPanel()).not.toBeNull();
listMock.mockResolvedValueOnce(response([att({ id: 'b1' })]));
props.itemId = 'item-b';
flushSync();
await settle();
expect(confirmPanel()).toBeNull();
expect(deleteMock).not.toHaveBeenCalled();
});
it('drops an open confirmation when another surface deletes that row', async () => {
// The tile the confirmation is anchored to has just been unmounted, so
// the menu would be left pointing at a detached element — and the
// question it is asking has already been answered.
listMock.mockResolvedValue(response([att({ id: 'a1' }), att({ id: 'a2' })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
openConfirm();
expect(confirmPanel()).not.toBeNull();
broadcastDeletion('a1');
flushSync();
expect(confirmPanel()).toBeNull();
expect(deleteMock).not.toHaveBeenCalled();
});
it('refuses a delete click that lands after the ITEM already switched', async () => {
@@ -940,18 +1072,16 @@ describe('ItemAttachmentStrip', () => {
expect(deleteButtons()).toHaveLength(1);
listMock.mockResolvedValueOnce(response([att({ id: 'b1' })]));
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
props.itemId = 'item-b';
// No flushSync between the switch and the click: that IS the window.
deleteButtons()[0].click();
await settle();
// Not even prompted — the tile the user aimed at no longer exists.
expect(confirmSpy).not.toHaveBeenCalled();
expect(confirmPanel()).toBeNull();
expect(deleteMock).not.toHaveBeenCalled();
expect(notifyDeletedMock).not.toHaveBeenCalled();
expect(tiles()[0].getAttribute('aria-label')).toContain('b1.png');
confirmSpy.mockRestore();
});
it('refuses a delete click that lands after the WORKSPACE already switched', async () => {
@@ -964,15 +1094,37 @@ describe('ItemAttachmentStrip', () => {
await settle();
listMock.mockResolvedValueOnce(response([att({ id: 'ws2-row' })]));
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
props.wsSlug = 'ws2';
deleteButtons()[0].click();
await settle();
expect(confirmSpy).not.toHaveBeenCalled();
expect(confirmPanel()).toBeNull();
expect(deleteMock).not.toHaveBeenCalled();
expect(tiles()[0].getAttribute('aria-label')).toContain('ws2-row.png');
confirmSpy.mockRestore();
});
it('refuses a CONFIRMATION that lands after the item already switched', async () => {
// The window `window.confirm` did not have: it blocked the thread, so
// the entry fence taken when the `×` was clicked was still true by
// definition when it returned. An in-app confirmation can sit on screen
// across a switch, so the fence is re-checked at the point that
// actually sends the request (TASK-2425).
listMock.mockResolvedValueOnce(response([att({ id: 'a1' })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
openConfirm();
listMock.mockResolvedValueOnce(response([att({ id: 'b1' })]));
props.itemId = 'item-b';
// No flushSync: the prompt is still up and the props already read B.
clickConfirm();
await settle();
expect(deleteMock).not.toHaveBeenCalled();
expect(notifyDeletedMock).not.toHaveBeenCalled();
expect(tiles()[0].getAttribute('aria-label')).toContain('b1.png');
});
it('rolls the tile back and toasts when the delete fails', async () => {
@@ -982,8 +1134,8 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
openConfirm();
clickConfirm();
await settle();
expect(tiles()).toHaveLength(2);
@@ -991,7 +1143,6 @@ describe('ItemAttachmentStrip', () => {
expect(notifyDeletedMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledOnce();
expect(String(toastMock.mock.calls[0][0])).toContain('a1.png');
confirmSpy.mockRestore();
});
it('does not roll a failed delete back into a DIFFERENT item strip', async () => {
@@ -1010,9 +1161,8 @@ describe('ItemAttachmentStrip', () => {
failDelete = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
flushSync();
openConfirm();
clickConfirm();
expect(tiles()).toHaveLength(0); // optimistic removal happened
// Switch to B before the delete settles.
@@ -1028,7 +1178,6 @@ describe('ItemAttachmentStrip', () => {
expect(names.some((n) => n?.includes('a1.png'))).toBe(false);
expect(names.some((n) => n?.includes('b1.png'))).toBe(true);
expect(toastMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('rolls back only the failed row, never resurrecting a concurrent success', async () => {
@@ -1048,11 +1197,10 @@ describe('ItemAttachmentStrip', () => {
failFirst = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click(); // a1 — in flight, will fail
flushSync();
deleteButtons()[0].click(); // now b1 — resolves immediately
openConfirm(); // a1 — in flight, will fail
clickConfirm();
openConfirm(); // now b1 — resolves immediately
clickConfirm();
await settle();
failFirst(new Error('boom'));
@@ -1063,7 +1211,6 @@ describe('ItemAttachmentStrip', () => {
expect(names.some((n) => n.includes('b1.png'))).toBe(false); // stays deleted
// ...and restored at its original position, not appended.
expect(names[0]).toContain('a1.png');
confirmSpy.mockRestore();
});
it('still announces the deletion when the delete 404s', async () => {
@@ -1075,13 +1222,12 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
openConfirm();
clickConfirm();
await settle();
expect(notifyDeletedMock).toHaveBeenCalledWith('a1');
expect(invalidateMock).toHaveBeenCalledWith('ws', 'a1');
confirmSpy.mockRestore();
});
it('still announces a 404 delete when the view switched under it', async () => {
@@ -1102,9 +1248,8 @@ describe('ItemAttachmentStrip', () => {
failDelete = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
flushSync();
openConfirm();
clickConfirm();
listMock.mockResolvedValue(response([att({ id: 'b1' })]));
props.itemId = 'item-b';
@@ -1121,7 +1266,6 @@ describe('ItemAttachmentStrip', () => {
expect(names.some((n) => n.includes('a1.png'))).toBe(false);
expect(names.some((n) => n.includes('b1.png'))).toBe(true);
expect(toastMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('does not roll back a failed delete that another surface already announced', async () => {
@@ -1139,9 +1283,8 @@ describe('ItemAttachmentStrip', () => {
failDelete = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
flushSync();
openConfirm();
clickConfirm();
broadcastDeletion('a1');
flushSync();
@@ -1151,7 +1294,6 @@ describe('ItemAttachmentStrip', () => {
const names = tiles().map((el) => el.getAttribute('aria-label') ?? '');
expect(names.some((n) => n.includes('a1.png'))).toBe(false);
confirmSpy.mockRestore();
});
it('keeps the tile removed when the delete 404s (already gone)', async () => {
@@ -1164,13 +1306,12 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
openConfirm();
clickConfirm();
await settle();
expect(tiles()).toHaveLength(1);
expect(toastMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('names permission as the reason on a 403, and restores the tile', async () => {
@@ -1180,13 +1321,12 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
openConfirm();
clickConfirm();
await settle();
expect(tiles()).toHaveLength(1);
expect(String(toastMock.mock.calls[0][0])).toContain("don't have permission");
confirmSpy.mockRestore();
});
it('still rolls back and toasts when a Retry re-ran the load mid-delete', async () => {
@@ -1210,9 +1350,8 @@ describe('ItemAttachmentStrip', () => {
failDelete = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
flushSync();
openConfirm();
clickConfirm();
expect(tiles()).toHaveLength(0); // optimistic removal
// Retry while the delete is still in flight.
@@ -1228,7 +1367,6 @@ describe('ItemAttachmentStrip', () => {
expect(toastMock).toHaveBeenCalledOnce();
expect(String(toastMock.mock.calls[0][0])).toContain('survivor.png');
expect(notifyDeletedMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('suppresses a failed delete when the WORKSPACE changed under it', async () => {
@@ -1249,9 +1387,8 @@ describe('ItemAttachmentStrip', () => {
failDelete = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
flushSync();
openConfirm();
clickConfirm();
expect(tiles()).toHaveLength(0);
// Retry clicked, THEN the workspace swapped before the effect flushed —
@@ -1270,7 +1407,6 @@ describe('ItemAttachmentStrip', () => {
expect(names.some((n) => n.includes('survivor.png'))).toBe(false);
expect(names.some((n) => n.includes('other-ws.png'))).toBe(true);
expect(toastMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('still suppresses a failed delete after an A→B→A round trip', async () => {
@@ -1290,9 +1426,8 @@ describe('ItemAttachmentStrip', () => {
failDelete = reject;
})
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deleteButtons()[0].click();
flushSync();
openConfirm();
clickConfirm();
listMock.mockResolvedValue(response([att({ id: 'b1' })]));
props.itemId = 'item-b';
@@ -1309,7 +1444,6 @@ describe('ItemAttachmentStrip', () => {
expect(tiles()).toHaveLength(0);
expect(toastMock).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
// ── Upload refresh (TASK-2385) ────────────────────────────────────────
@@ -1655,12 +1789,9 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
deleteButtons()[0].click();
await settle();
openConfirm();
expect(String(confirmSpy.mock.calls[0][0])).toContain("still used in this item's content");
confirmSpy.mockRestore();
expect(promptText()).toContain("still used in this item's content");
});
it('falls back to persisted content when the live read throws', async () => {
@@ -1674,11 +1805,8 @@ describe('ItemAttachmentStrip', () => {
mountStrip('item-a');
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
deleteButtons()[0].click();
await settle();
openConfirm();
expect(String(confirmSpy.mock.calls[0][0])).toContain("still used in this item's content");
confirmSpy.mockRestore();
expect(promptText()).toContain("still used in this item's content");
});
});
@@ -19,6 +19,8 @@
type StorageFilterSelections
} from '$lib/attachments/storageFilters';
import AttachmentIcon from '$lib/attachments/icons/AttachmentIcon.svelte';
import Menu from '$lib/components/common/Menu.svelte';
import AttachmentDeleteConfirm from '$lib/components/attachments/AttachmentDeleteConfirm.svelte';
import { viewIdentity, createFence, createPaintFence } from '$lib/attachments/viewFence';
// ── Props ────────────────────────────────────────────────────────────────
@@ -49,6 +51,22 @@
let loading = $state(true);
let usage = $state<WorkspaceStorageInfo | null>(null);
let attachments = $state<AttachmentListItem[]>([]);
/**
* The delete confirmation currently on screen (PLAN-2392 DR-18 /
* TASK-2425). This row used to raise a browser-native `confirm()`; every
* attachment delete now goes through the same in-app drill-down — Cancel
* first, destructive row last, prompt back-referenced by
* `aria-describedby`. The WORDING stays this surface's own: the strip's
* "referenced in this item's content" check has no meaning in a
* workspace-wide list, and what matters here is the GC grace period.
*/
let pendingDelete = $state<{
att: AttachmentListItem;
anchor: HTMLElement | null;
prompt: string;
} | null>(null);
const uid = $props.id();
const promptId = `storage-delete-note-${uid}`;
let total = $state(0);
let limit = $state(50);
let offset = $state(0);
@@ -248,6 +266,10 @@
attachments = [];
total = 0;
usage = null;
// A confirmation left up for a row from the previous workspace
// goes with it: the button it is anchored to has just been
// unmounted, and `confirmDelete`'s fence would refuse it anyway.
pendingDelete = null;
void loadWorkspaceView();
return;
}
@@ -400,6 +422,49 @@
// ── Actions ──────────────────────────────────────────────────────────────
/**
* Open the delete confirmation (PLAN-2392 DR-18 / TASK-2425).
*
* ENTRY fence, for the same reason `handleDelete` re-takes it below: the
* clicked row was painted for the workspace this tab has LOADED, which
* during the prop-update → effect-flush window is not necessarily the one
* `wsSlug` already names.
*/
function requestDelete(att: AttachmentListItem, anchor: HTMLElement | null) {
if (!paint.isCurrent()) return;
pendingDelete = {
att,
anchor,
prompt: `Delete ${att.filename}? The blob is reclaimed by garbage collection after a grace period.`,
};
}
/** Escape / outside-click. `Menu` handles the Escape refocus itself. */
function dismissDelete() {
pendingDelete = null;
}
/** The Cancel row — returns focus to the button it was anchored to. */
function cancelDelete() {
const anchor = pendingDelete?.anchor;
pendingDelete = null;
anchor?.focus();
}
/**
* The user confirmed. `confirm()` blocked the thread, so the entry fence
* was still true by definition when it returned; an in-app confirmation
* does not, and the workspace can change while it is up — so the fence is
* re-checked at the point that actually sends the request.
*/
function confirmDelete() {
const pending = pendingDelete;
pendingDelete = null;
if (!pending) return;
if (!paint.isCurrent()) return;
void handleDelete(pending.att);
}
async function handleDelete(att: AttachmentListItem) {
// ENTRY fence (fence 3). The clicked row was painted for the workspace
// this tab has LOADED, which during the prop-update → effect-flush window
@@ -417,10 +482,6 @@
// continuation toast and refetch (Codex round 3).
const req = viewFence.begin();
const ok = confirm(
`Delete ${att.filename}? The blob is reclaimed by garbage collection after a grace period.`
);
if (!ok) return;
try {
await api.attachments.delete(reqWsSlug, att.id);
// Same broadcast the item attachment strip does (PLAN-2382 /
@@ -716,7 +777,7 @@
<button
type="button"
class="btn btn-small btn-remove"
onclick={() => handleDelete(att)}
onclick={(e) => requestDelete(att, e.currentTarget)}
>
Delete
</button>
@@ -751,6 +812,33 @@
{/if}
</div>
<!--
The delete confirmation (PLAN-2392 DR-18 / TASK-2425) — the same in-app
drill-down the item strip and the options panel show, anchored to the row's
own Delete button. Props are read through `?.` because `Menu` places itself
in a `tick().then()` that can run after this block is torn down.
-->
{#if pendingDelete}
<Menu
open
onclose={dismissDelete}
trigger={pendingDelete?.anchor ?? undefined}
mode="portal"
width={272}
sheetOnMobile
sheetTitle="Delete {pendingDelete?.att.filename ?? ''}"
ariaLabel="Delete {pendingDelete?.att.filename ?? ''}"
focusKey={pendingDelete?.att.id}
>
<AttachmentDeleteConfirm
prompt={pendingDelete?.prompt ?? ''}
{promptId}
oncancel={cancelDelete}
onconfirm={confirmDelete}
/>
</Menu>
{/if}
<style>
/* ── Local copies of the parent settings page primitives ────────────────
* Svelte 5 scopes styles to the component, so a child cannot reach the
@@ -141,6 +141,27 @@ describe('StorageTab workspace switching', () => {
return target.textContent ?? '';
}
/**
* The delete confirmation is an in-app drill-down as of TASK-2425
* (PLAN-2392 DR-18) — the same one the item strip and the options panel
* show — rather than a browser-native `confirm()`. It is portaled to
* <body> like every other Menu, so it is queried document-wide.
*/
function confirmPanel(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="menu"]');
}
/** Click a row's Delete, then the destructive row of the confirmation. */
function deleteAndConfirm(index = 0) {
Array.from(target.querySelectorAll<HTMLButtonElement>('.btn-remove'))[index].click();
flushSync();
const confirmRow = Array.from(
document.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]')
).find((el) => el.querySelector('.mi-label')?.textContent?.trim() === 'Delete file');
confirmRow!.click();
flushSync();
}
it('loads exactly once on mount', async () => {
mountTab();
await settle();
@@ -277,9 +298,7 @@ describe('StorageTab workspace switching', () => {
const slowDelete = deferred<void>();
deleteMock.mockReturnValueOnce(slowDelete.promise);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
target.querySelector<HTMLButtonElement>('.btn-remove')?.click();
flushSync();
deleteAndConfirm();
expect(deleteMock).toHaveBeenCalledWith('ws-a', 'a1');
listMock.mockResolvedValueOnce(response([att({ id: 'b1', filename: 'from-b.pdf' })]));
@@ -299,7 +318,6 @@ describe('StorageTab workspace switching', () => {
expect(toastMock).not.toHaveBeenCalled();
expect(listMock.mock.calls.length).toBe(listCalls);
expect(text()).toContain('from-b.pdf');
confirmSpy.mockRestore();
});
it('still suppresses a delete continuation after an A→B→A round trip', async () => {
@@ -313,9 +331,7 @@ describe('StorageTab workspace switching', () => {
const slowDelete = deferred<void>();
deleteMock.mockReturnValueOnce(slowDelete.promise);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
target.querySelector<HTMLButtonElement>('.btn-remove')?.click();
flushSync();
deleteAndConfirm();
expect(deleteMock).toHaveBeenCalledWith('ws-a', 'a1');
props.wsSlug = 'ws-b';
@@ -339,7 +355,6 @@ describe('StorageTab workspace switching', () => {
// against ws-a (Codex round 4).
expect(listMock.mock.calls.length).toBe(listCalls + 1);
expect(listMock).toHaveBeenLastCalledWith('ws-a', expect.anything());
confirmSpy.mockRestore();
});
it('does not toast or refetch for a delete that resolves after unmount', async () => {
@@ -349,9 +364,7 @@ describe('StorageTab workspace switching', () => {
const slowDelete = deferred<void>();
deleteMock.mockReturnValueOnce(slowDelete.promise);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
target.querySelector<HTMLButtonElement>('.btn-remove')?.click();
flushSync();
deleteAndConfirm();
unmount(instance!);
instance = undefined;
@@ -366,7 +379,6 @@ describe('StorageTab workspace switching', () => {
expect(announceMock).toHaveBeenCalledWith('ws-a', 'a1');
expect(toastMock).not.toHaveBeenCalled();
expect(listMock.mock.calls.length).toBe(listCalls);
confirmSpy.mockRestore();
});
it('does not toast for a list or usage request that fails after unmount', async () => {
@@ -401,7 +413,6 @@ describe('StorageTab workspace switching', () => {
mountTab();
await settle();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
props.wsSlug = 'ws-b';
// No flushSync between the switch and the click: that IS the window.
target.querySelector<HTMLButtonElement>('.btn-remove')?.click();
@@ -409,8 +420,9 @@ describe('StorageTab workspace switching', () => {
await settle();
expect(deleteMock).not.toHaveBeenCalled();
expect(confirmSpy).not.toHaveBeenCalled();
confirmSpy.mockRestore();
// Not even prompted — the row the user aimed at belongs to a workspace
// this tab is no longer showing.
expect(confirmPanel()).toBeNull();
});
it('ignores a superseded usage response and its error toast', async () => {