fix(documents): stop Backspace deleting a document, and show the key that does

A focused card deleted on Backspace as well as Delete. The timeline's
roving tabindex gives a card focus the moment someone tabs into the
grid, Backspace is the reflex "go back" key, and a card carries no
delete control at all — so the reflex press removed a medical document
with nothing but a transient undo toast behind it.

Delete alone now deletes. The key resolution moves into a pure helper
in vault-utils so the contract is testable without a render harness,
and the card stops keeping that contract in a code comment: the overlay
button carries aria-keyshortcuts, and a kbd chip appears while the card
holds focus. The label is per-locale (Entf / Suppr / Supr / Canc).
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 14:47:56 +02:00
1 parent 20824aa798
commit e4a4c996b7
10 files changed
+111 -9

No files matched your search

+2 -1
View File
@@ -8621,7 +8621,8 @@
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
"processing": "Wird verarbeitet…",
"ready": "Bereit"
"ready": "Bereit",
"deleteKey": "Entf"
},
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
+2 -1
View File
@@ -8621,7 +8621,8 @@
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload",
"processing": "Processing…",
"ready": "Ready"
"ready": "Ready",
"deleteKey": "Del"
},
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
+2 -1
View File
@@ -8621,7 +8621,8 @@
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida",
"processing": "Procesando…",
"ready": "Listo"
"ready": "Listo",
"deleteKey": "Supr"
},
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
+2 -1
View File
@@ -8621,7 +8621,8 @@
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué",
"processing": "Traitement en cours…",
"ready": "Prêt"
"ready": "Prêt",
"deleteKey": "Suppr"
},
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
+2 -1
View File
@@ -8621,7 +8621,8 @@
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito",
"processing": "Elaborazione…",
"ready": "Pronto"
"ready": "Pronto",
"deleteKey": "Canc"
},
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
+2 -1
View File
@@ -8621,7 +8621,8 @@
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie",
"processing": "Przetwarzanie…",
"ready": "Gotowe"
"ready": "Gotowe",
"deleteKey": "Del"
},
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
@@ -65,6 +65,38 @@ describe("<DocumentCard>", () => {
expect(html).not.toContain("Download only");
});
it("announces and shows the Delete shortcut when the card is deletable", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
onDelete={noop}
highlighted={false}
/>,
);
// The keyboard contract used to live only in a code comment. It is now
// announced to assistive tech and drawn while the card holds focus.
expect(html).toContain('aria-keyshortcuts="Delete"');
expect(html).toContain('data-slot="document-delete-hint"');
expect(html).toContain("Del");
});
it("offers no delete shortcut when the card carries no delete handler", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain("aria-keyshortcuts");
expect(html).not.toContain('data-slot="document-delete-hint"');
});
it("shows the download-only badge for attachment-class documents", () => {
const html = render(
<DocumentCard
@@ -6,6 +6,7 @@ import {
buildVaultListApiSearch,
classifyUploadFailure,
countActiveFilters,
documentCardKeyAction,
documentDateKey,
expandRangeSelection,
formatBytes,
@@ -411,3 +412,22 @@ describe("isRecentlyUploaded / isDocumentProcessing / hasProcessingDocument", ()
expect(hasProcessingDocument([], NOW)).toBe(false);
});
});
describe("documentCardKeyAction", () => {
it("deletes on Delete and selects on Space", () => {
expect(documentCardKeyAction("Delete")).toBe("delete");
expect(documentCardKeyAction(" ")).toBe("select");
});
it("does NOT delete on Backspace", () => {
// The reflex "go back" key on a card that takes focus by default, with
// no other delete control and only a transient undo toast behind it.
expect(documentCardKeyAction("Backspace")).toBeNull();
});
it("ignores every other key", () => {
for (const key of ["Enter", "Escape", "a", "ArrowDown", "Del"]) {
expect(documentCardKeyAction(key)).toBeNull();
}
});
});
+28 -3
View File
@@ -29,6 +29,7 @@ import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import {
documentCardKeyAction,
documentDateKey,
formatBytes,
isDocumentProcessing,
@@ -241,7 +242,15 @@ export function DocumentCard({
(the checkbox stays reachable via its own z-10). Keyboard contract
(roving tabindex owned by the timeline): Enter opens (native
click), Space toggles selection, Delete removes with undo. Touch:
press-and-hold selects instead of opening. */}
press-and-hold selects instead of opening.
Backspace is deliberately NOT a delete key here. It is the reflex
"go back" key, the roving tabindex hands a card focus the moment
someone tabs into the grid, and a card carries no other delete
control — so the reflex press destroyed a medical document with
nothing but a transient undo toast to catch it. Delete alone,
announced through `aria-keyshortcuts` and shown as a `<kbd>` chip
while the card holds focus. */}
<button
type="button"
data-slot="document-open"
@@ -254,10 +263,11 @@ export function DocumentCard({
onOpen(document.id);
}}
onKeyDown={(e) => {
if (e.key === " ") {
const action = documentCardKeyAction(e.key);
if (action === "select") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
} else if (action === "delete") {
e.preventDefault();
onDelete?.(document.id);
}
@@ -279,8 +289,23 @@ export function DocumentCard({
onPrefetch?.(document.id);
}}
aria-label={t("documents.card.openLabel", { title })}
aria-keyshortcuts={onDelete ? "Delete" : undefined}
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
/>
{/* The only visible affordance for deleting a single card. Shown while
the card holds focus (same `group-focus-within` trigger the checkbox
uses) and hidden from the accessibility tree, because
`aria-keyshortcuts` on the overlay button already announces the same
key. `pointer-events-none` keeps it out of the click target. */}
{onDelete ? (
<kbd
aria-hidden
data-slot="document-delete-hint"
className="border-border bg-background text-muted-foreground pointer-events-none absolute right-2 bottom-2 hidden rounded border px-1.5 font-mono text-xs opacity-0 transition-opacity group-focus-within:opacity-100 sm:inline-block"
>
{t("documents.card.deleteKey")}
</kbd>
) : null}
</Card>
);
}
+19
View File
@@ -416,3 +416,22 @@ export function parseUploadResponse(
}
return classifyUploadFailure(status, envelope?.meta);
}
/** What a keypress on a focused document card does. */
export type DocumentCardKeyAction = "select" | "delete" | null;
/**
* Resolve the action for a keypress on a focused vault card.
*
* `Backspace` is deliberately absent. The timeline's roving tabindex hands a
* card focus the moment someone tabs into the grid, `Backspace` is the reflex
* "go back" key, and a card carries no other delete control — so the reflex
* press deleted a medical document with nothing but a transient undo toast
* between the person and the loss. `Delete` alone deletes, and the card shows
* that key while it holds focus.
*/
export function documentCardKeyAction(key: string): DocumentCardKeyAction {
if (key === " ") return "select";
if (key === "Delete") return "delete";
return null;
}