fix: restore focus to a stable anchor, not a removed row, after asset delete
Fix round 1 for the Task 11 asset library review. I1 (Important): a successful delete removed the row from state, so remove()'s reuse of closeDetail() queued .focus() on a now-detached button -- a silent no-op that stranded focus on <body>. Splits closeDetail() (cancel/close, row still on screen, restores focus to the trigger) from a new closeDetailAfterRemoval() (post-delete, focuses the page heading, the one anchor guaranteed to survive any list change) so the two paths stop sharing a helper that only one of them can safely use. Also closes four Minors from the same review round: - route-contract.test.ts's "27-route inventory" test title corrected to 28. - canHardDelete's usageCount clause gets its own isolating assertion (every prior case used usageCount: 0, so that clause was never independently falsified). - Dropped a duplicate role="status" announcement on a listAssets load failure; the existing role="alert" paragraph is now the sole announcement. - Added success-path tests for archive() and remove() via a new recordingGateway() test helper that pins the exact gateway call shape (expectedVersion, managementStatus: ARCHIVED, idempotency keys), not just the resulting UI text; the delete-success test also pins the I1 focus fix so a regression back to the removed trigger fails loudly. See task-11-report.md's "Fix round 1" section for the RED/GREEN evidence (the pre-fix code reliably crashes the test worker rather than failing the assertion cleanly -- explained there) and the correction to this task's original claim about matching publication-list.tsx's focus pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b11aa94f1c
commit
3ab04a236d
@@ -31,6 +31,7 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
||||
const [notice, setNotice] = useState("");
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const detailHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
const pageHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
const openAssetId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,8 +45,10 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isAbortError(error)) return;
|
||||
// The `role="alert"` paragraph below is the sole announcement for a
|
||||
// load failure -- also routing it through `notice`'s `role="status"`
|
||||
// paragraph would announce the same sentence twice.
|
||||
setListStatus("ERROR");
|
||||
setNotice("Asset 목록을 불러오지 못했습니다.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [props.gateway]);
|
||||
@@ -71,11 +74,27 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dismissing the panel without changing the list (cancel/close): the row
|
||||
// that opened it is still on screen, so restore focus there.
|
||||
function closeDetail() {
|
||||
setSelected(null);
|
||||
queueMicrotask(() => triggerRef.current?.focus());
|
||||
}
|
||||
|
||||
// Fix round 1 (I1). A successful delete removes the row from `assets`, so
|
||||
// by the time this runs `triggerRef.current` -- still set, since React
|
||||
// never nulls a plain ref -- points at a button that is no longer attached
|
||||
// to the document. `.focus()` on a detached element is a silent no-op, so
|
||||
// reusing `closeDetail` here left focus stranded on `<body>`. The page
|
||||
// heading is the one thing in this screen guaranteed to survive any list
|
||||
// change (the list itself can disappear into the empty state), so a
|
||||
// deletion moves focus there instead of trying to reuse a node it just
|
||||
// destroyed.
|
||||
function closeDetailAfterRemoval() {
|
||||
setSelected(null);
|
||||
queueMicrotask(() => pageHeadingRef.current?.focus());
|
||||
}
|
||||
|
||||
async function archive(detail: AssetDetail) {
|
||||
setPending(true);
|
||||
try {
|
||||
@@ -106,7 +125,7 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
||||
});
|
||||
setAssets((current) => current.filter((item) => item.id !== detail.asset.id));
|
||||
setNotice("삭제했습니다.");
|
||||
closeDetail();
|
||||
closeDetailAfterRemoval();
|
||||
} catch (error) {
|
||||
setNotice(
|
||||
isStudioGatewayError(error) ? error.problem.detail : "삭제하지 못했습니다.",
|
||||
@@ -120,7 +139,7 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
||||
<div className="studio-page studio-assets-page">
|
||||
<header className="studio-page-heading">
|
||||
<p className="studio-eyebrow">ASSET LIBRARY</p>
|
||||
<h1>Asset</h1>
|
||||
<h1 ref={pageHeadingRef} tabIndex={-1}>Asset</h1>
|
||||
<p>
|
||||
업로드한 Asset을 검색하고 사용처를 확인하며, 사용하지 않는 Asset을
|
||||
정리합니다.
|
||||
|
||||
Reference in New Issue
Block a user