mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
20824aa798
commit
e4a4c996b7
10 files changed
+111
-9
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user