From b973e1099df151dc874c2c5b7dfdd4cdd947ed1d Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 17 Jul 2026 13:22:25 +0800 Subject: [PATCH] fix: harden favorites drawer interactions --- src/content/market/favorites-drawer.ts | 293 ++++++++++++++++++++----- tests/favorites-drawer.test.ts | 186 +++++++++++++++- 2 files changed, 423 insertions(+), 56 deletions(-) diff --git a/src/content/market/favorites-drawer.ts b/src/content/market/favorites-drawer.ts index 501764d..19a1dc5 100644 --- a/src/content/market/favorites-drawer.ts +++ b/src/content/market/favorites-drawer.ts @@ -22,6 +22,20 @@ export interface FavoritesDrawerController { const DRAWER_ID = "sces-favorites-drawer"; const activeDrawers = new WeakMap(); +type DrawerView = + | { kind: "all" } + | { folderId: string; kind: "folder" }; + +type DrawerFocusDescriptor = + | { + end: number | null; + kind: "search"; + start: number | null; + } + | { authorId: string; kind: "checkbox" } + | { folderId: string; kind: "folder" } + | { attribute: string; kind: "attribute"; value: string }; + export function createFavoritesDrawer( document: Document, handlers: FavoritesDrawerHandlers, @@ -58,9 +72,10 @@ export function createFavoritesDrawer( let disposed = false; let errorMessage = ""; let isOpen = false; + let pending = false; let searchQuery = ""; let state = emptyState(); - let view: "all" | string = "all"; + let view: DrawerView = { kind: "all" }; let selectedAuthorIds = new Set(); const controller: FavoritesDrawerController = { @@ -100,6 +115,9 @@ export function createFavoritesDrawer( if (event.key !== "Escape" || !isOpen) { return; } + if (document.querySelector("[data-sces-favorite-folder-dialog]")) { + return; + } event.preventDefault(); closeDrawer(); }; @@ -120,18 +138,20 @@ export function createFavoritesDrawer( return; } if (button.dataset.scesFavoritesDrawer === "view-all") { - view = "all"; + view = { kind: "all" }; renderDrawer(); return; } - const folderId = button.dataset.scesFavoritesFolderId; - if (folderId) { - view = folderId; + if (button.hasAttribute("data-sces-favorites-folder-id")) { + view = { + folderId: button.getAttribute("data-sces-favorites-folder-id") ?? "", + kind: "folder" + }; renderDrawer(); return; } - if (busy) { + if (isInteractionLocked()) { return; } @@ -140,21 +160,30 @@ export function createFavoritesDrawer( return; } - const renameFolderId = button.dataset.scesFavoritesFolderRename; - if (renameFolderId) { + if (button.hasAttribute("data-sces-favorites-folder-rename")) { + const renameFolderId = + button.getAttribute("data-sces-favorites-folder-rename") ?? ""; invokeHandler(() => handlers.onRenameFolder(renameFolderId)); return; } - const deleteFolderId = button.dataset.scesFavoritesFolderDelete; - if (deleteFolderId) { + if (button.hasAttribute("data-sces-favorites-folder-delete")) { + const deleteFolderId = + button.getAttribute("data-sces-favorites-folder-delete") ?? ""; invokeHandler(() => handlers.onDeleteFolder(deleteFolderId)); return; } - const removeAuthorId = button.dataset.scesFavoritesRemoveAuthorId; - if (removeAuthorId && view !== "all") { - invokeHandler(() => handlers.onRemoveCreatorFromFolder(removeAuthorId, view)); + if ( + button.hasAttribute("data-sces-favorites-remove-author-id") && + view.kind === "folder" + ) { + const removeAuthorId = + button.getAttribute("data-sces-favorites-remove-author-id") ?? ""; + const folderId = view.folderId; + invokeHandler(() => + handlers.onRemoveCreatorFromFolder(removeAuthorId, folderId) + ); return; } @@ -166,11 +195,15 @@ export function createFavoritesDrawer( return; } - if (button.dataset.scesFavoritesDrawer === "import-folder" && view !== "all") { + if ( + button.dataset.scesFavoritesDrawer === "import-folder" && + view.kind === "folder" + ) { const authorIds = creatorsInCurrentView().map((creator) => creator.authorId); + const folderId = view.folderId; const message = `将当前收藏夹的 ${authorIds.length} 位达人导入秒探吗?`; if (confirmImport(message)) { - invokeHandler(() => handlers.onImportFolder(view, authorIds)); + invokeHandler(() => handlers.onImportFolder(folderId, authorIds)); } } }; @@ -182,25 +215,15 @@ export function createFavoritesDrawer( target.dataset.scesFavoritesDrawer === "search" ) { searchQuery = target.value; - const selectionStart = target.selectionStart; - const selectionEnd = target.selectionEnd; + target.focus(); renderDrawer(); - const search = drawer.querySelector( - '[data-sces-favorites-drawer="search"]' - ); - if (search instanceof HTMLInputElement) { - search.focus(); - if (selectionStart !== null && selectionEnd !== null) { - search.setSelectionRange(selectionStart, selectionEnd); - } - } } }; const onDrawerChange = (event: Event) => { const target = event.target; if ( - busy || + isInteractionLocked() || !(target instanceof HTMLInputElement) || target.type !== "checkbox" ) { @@ -222,7 +245,7 @@ export function createFavoritesDrawer( drawer.addEventListener("click", onDrawerClick); drawer.addEventListener("input", onDrawerInput); drawer.addEventListener("change", onDrawerChange); - document.addEventListener("keydown", onDocumentKeydown); + document.addEventListener("keydown", onDocumentKeydown, true); renderDrawer(); activeDrawers.set(document, controller); @@ -261,8 +284,12 @@ export function createFavoritesDrawer( } function renderDrawer(): void { - if (view !== "all" && !state.folders.some((folder) => folder.id === view)) { - view = "all"; + const focusDescriptor = captureFocusedControl(); + if (view.kind === "folder") { + const folderId = view.folderId; + if (!state.folders.some((folder) => folder.id === folderId)) { + view = { kind: "all" }; + } } const currentCreators = creatorsInCurrentView(); @@ -291,6 +318,7 @@ export function createFavoritesDrawer( fragment.append(createCreatorList(visibleCreators)); fragment.append(createFooter(selectedCount)); drawer.replaceChildren(fragment); + restoreFocusedControl(focusDescriptor); } function createHeader(): HTMLElement { @@ -306,12 +334,13 @@ export function createFavoritesDrawer( const createFolder = createButton(document, "新建收藏夹"); createFolder.dataset.scesFavoritesDrawer = "create-folder"; - createFolder.disabled = busy; + createFolder.disabled = isInteractionLocked(); applySecondaryButtonStyles(createFolder); - const close = createButton(document, "关闭收藏夹"); + const close = createButton(document, "x"); close.dataset.scesFavoritesDrawer = "close"; close.setAttribute("aria-label", "关闭收藏夹"); + close.title = "关闭收藏夹"; applyIconButtonStyles(close); actions.append(createFolder, close); @@ -326,8 +355,8 @@ export function createFavoritesDrawer( const allButton = createButton(document, "全部收藏"); allButton.dataset.scesFavoritesDrawer = "view-all"; - allButton.setAttribute("aria-current", String(view === "all")); - applyViewButtonStyles(allButton, view === "all"); + allButton.setAttribute("aria-current", String(view.kind === "all")); + applyViewButtonStyles(allButton, view.kind === "all"); navigation.append(allButton); state.folders.forEach((folder) => { @@ -336,19 +365,21 @@ export function createFavoritesDrawer( const viewButton = createButton(document, folder.name); viewButton.dataset.scesFavoritesFolderId = folder.id; - viewButton.setAttribute("aria-current", String(view === folder.id)); - applyViewButtonStyles(viewButton, view === folder.id); + const isCurrentFolder = + view.kind === "folder" && view.folderId === folder.id; + viewButton.setAttribute("aria-current", String(isCurrentFolder)); + applyViewButtonStyles(viewButton, isCurrentFolder); const rename = createButton(document, "重命名"); rename.dataset.scesFavoritesFolderRename = folder.id; rename.setAttribute("aria-label", `重命名收藏夹 ${folder.name}`); - rename.disabled = busy; + rename.disabled = isInteractionLocked(); applyTextActionStyles(rename); const remove = createButton(document, "删除"); remove.dataset.scesFavoritesFolderDelete = folder.id; remove.setAttribute("aria-label", `删除收藏夹 ${folder.name}`); - remove.disabled = busy; + remove.disabled = isInteractionLocked(); applyDangerActionStyles(remove); row.append(viewButton, rename, remove); @@ -393,7 +424,7 @@ export function createFavoritesDrawer( const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = selectedAuthorIds.has(creator.authorId); - checkbox.disabled = busy; + checkbox.disabled = isInteractionLocked(); checkbox.dataset.scesFavoritesSelectAuthorId = creator.authorId; checkbox.setAttribute("aria-label", `选择达人 ${creator.authorName}`); selectLabel.append(checkbox); @@ -406,7 +437,7 @@ export function createFavoritesDrawer( applyCreatorNameStyles(name); details.append(name); - if (view === "all") { + if (view.kind === "all") { const affiliation = document.createElement("p"); affiliation.textContent = `所属:${folderNamesForCreator(creator.authorId).join("、") || "未分类"}`; applyAffiliationStyles(affiliation); @@ -414,11 +445,11 @@ export function createFavoritesDrawer( } row.append(selectLabel, details); - if (view !== "all") { + if (view.kind === "folder") { const remove = createButton(document, "移出收藏夹"); remove.dataset.scesFavoritesRemoveAuthorId = creator.authorId; remove.setAttribute("aria-label", `将 ${creator.authorName} 移出当前收藏夹`); - remove.disabled = busy; + remove.disabled = isInteractionLocked(); applyDangerActionStyles(remove); row.append(remove); } @@ -429,6 +460,7 @@ export function createFavoritesDrawer( function createFooter(selectedCount: number): HTMLElement { const footer = document.createElement("footer"); + footer.dataset.scesFavoritesDrawer = "footer"; applyFooterStyles(footer); const selected = document.createElement("span"); @@ -437,18 +469,20 @@ export function createFavoritesDrawer( const actions = document.createElement("div"); applyFooterActionsStyles(actions); - if (view !== "all") { + if (view.kind === "folder") { const importFolder = createButton(document, "导入当前收藏夹全部达人"); importFolder.dataset.scesFavoritesDrawer = "import-folder"; - importFolder.disabled = busy; + importFolder.disabled = isInteractionLocked(); applySecondaryButtonStyles(importFolder); + applyFooterActionStyles(importFolder); actions.append(importFolder); } const importSelected = createButton(document, `导入已选 ${selectedCount} 位达人`); importSelected.dataset.scesFavoritesDrawer = "import-selected"; - importSelected.disabled = busy || selectedCount === 0; + importSelected.disabled = isInteractionLocked() || selectedCount === 0; applyPrimaryButtonStyles(importSelected); + applyFooterActionStyles(importSelected); actions.append(importSelected); footer.append(selected, actions); @@ -457,7 +491,7 @@ export function createFavoritesDrawer( function creatorsInCurrentView(): FavoriteCreator[] { const uniqueCreators = new Map(); - if (view === "all") { + if (view.kind === "all") { state.creators.forEach((creator) => { if (!uniqueCreators.has(creator.authorId)) { uniqueCreators.set(creator.authorId, creator); @@ -472,8 +506,12 @@ export function createFavoritesDrawer( creatorsById.set(creator.authorId, creator); } }); + const folderId = view.folderId; state.memberships.forEach((membership) => { - if (membership.folderId !== view || uniqueCreators.has(membership.authorId)) { + if ( + membership.folderId !== folderId || + uniqueCreators.has(membership.authorId) + ) { return; } const creator = creatorsById.get(membership.authorId); @@ -503,14 +541,138 @@ export function createFavoritesDrawer( return Array.from(names); } - function invokeHandler(handler: () => Promise): void { - if (busy || disposed) { + function captureFocusedControl(): DrawerFocusDescriptor | null { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement) || !drawer.contains(activeElement)) { + return null; + } + if ( + activeElement instanceof HTMLInputElement && + activeElement.dataset.scesFavoritesDrawer === "search" + ) { + return { + end: activeElement.selectionEnd, + kind: "search", + start: activeElement.selectionStart + }; + } + if ( + activeElement instanceof HTMLInputElement && + activeElement.type === "checkbox" && + activeElement.hasAttribute("data-sces-favorites-select-author-id") + ) { + return { + authorId: + activeElement.getAttribute("data-sces-favorites-select-author-id") ?? "", + kind: "checkbox" + }; + } + if (activeElement instanceof HTMLButtonElement) { + if (activeElement.hasAttribute("data-sces-favorites-folder-id")) { + return { + folderId: + activeElement.getAttribute("data-sces-favorites-folder-id") ?? "", + kind: "folder" + }; + } + const actionAttribute = [ + "data-sces-favorites-drawer", + "data-sces-favorites-folder-delete", + "data-sces-favorites-folder-rename", + "data-sces-favorites-remove-author-id" + ].find((attribute) => activeElement.hasAttribute(attribute)); + if (actionAttribute) { + return { + attribute: actionAttribute, + kind: "attribute", + value: activeElement.getAttribute(actionAttribute) ?? "" + }; + } + } + return null; + } + + function restoreFocusedControl(descriptor: DrawerFocusDescriptor | null): void { + if (!descriptor) { return; } - void handler().catch((error: unknown) => { - errorMessage = error instanceof Error ? error.message : "操作失败"; - renderDrawer(); - }); + + let target: HTMLElement | undefined; + if (descriptor.kind === "search") { + const search = drawer.querySelector( + '[data-sces-favorites-drawer="search"]' + ); + if (search instanceof HTMLInputElement) { + target = search; + const start = Math.min( + descriptor.start ?? search.value.length, + search.value.length + ); + const end = Math.min(descriptor.end ?? start, search.value.length); + search.setSelectionRange(start, end); + } + } else if (descriptor.kind === "checkbox") { + target = findElementByAttribute( + "data-sces-favorites-select-author-id", + descriptor.authorId + ); + } else if (descriptor.kind === "folder") { + target = findElementByAttribute( + "data-sces-favorites-folder-id", + descriptor.folderId + ); + } else { + target = findElementByAttribute(descriptor.attribute, descriptor.value); + } + + if (target && isFocusable(target)) { + target.focus(); + return; + } + + const fallbackSearch = drawer.querySelector( + '[data-sces-favorites-drawer="search"]' + ); + if (fallbackSearch instanceof HTMLInputElement && !fallbackSearch.disabled) { + fallbackSearch.focus(); + } + } + + function findElementByAttribute(attribute: string, value: string): HTMLElement | undefined { + return Array.from(drawer.querySelectorAll(`[${attribute}]`)).find( + (element) => element.getAttribute(attribute) === value + ); + } + + function isFocusable(element: HTMLElement): boolean { + return !( + (element instanceof HTMLButtonElement || element instanceof HTMLInputElement) && + element.disabled + ); + } + + function invokeHandler(handler: () => Promise): void { + if (isInteractionLocked() || disposed) { + return; + } + pending = true; + renderDrawer(); + void (async () => { + try { + await handler(); + } catch (error: unknown) { + errorMessage = error instanceof Error ? error.message : "操作失败"; + } finally { + pending = false; + if (!disposed) { + renderDrawer(); + } + } + })(); + } + + function isInteractionLocked(): boolean { + return busy || pending; } function dispose(): void { @@ -522,7 +684,7 @@ export function createFavoritesDrawer( drawer.removeEventListener("click", onDrawerClick); drawer.removeEventListener("input", onDrawerInput); drawer.removeEventListener("change", onDrawerChange); - document.removeEventListener("keydown", onDocumentKeydown); + document.removeEventListener("keydown", onDocumentKeydown, true); tab.remove(); drawer.remove(); if (activeDrawers.get(document) === controller) { @@ -706,13 +868,19 @@ function applyFooterStyles(footer: HTMLElement): void { footer.style.display = "flex"; footer.style.alignItems = "center"; footer.style.justifyContent = "space-between"; + footer.style.flexWrap = "wrap"; footer.style.gap = "12px"; footer.style.padding = "12px 16px"; + footer.style.width = "100%"; + footer.style.minWidth = "0"; + footer.style.boxSizing = "border-box"; footer.style.borderTop = "1px solid #e2e8f0"; } function applySelectedCountStyles(selected: HTMLElement): void { - selected.style.flex = "0 0 auto"; + selected.style.flex = "1 1 100%"; + selected.style.minWidth = "0"; + selected.style.boxSizing = "border-box"; selected.style.color = "#475569"; selected.style.fontSize = "12px"; } @@ -721,8 +889,19 @@ function applyFooterActionsStyles(actions: HTMLElement): void { actions.style.display = "flex"; actions.style.alignItems = "center"; actions.style.justifyContent = "flex-end"; + actions.style.flexWrap = "wrap"; actions.style.gap = "8px"; actions.style.minWidth = "0"; + actions.style.width = "100%"; + actions.style.boxSizing = "border-box"; +} + +function applyFooterActionStyles(button: HTMLButtonElement): void { + button.style.flex = "1 1 100%"; + button.style.width = "100%"; + button.style.minWidth = "0"; + button.style.maxWidth = "100%"; + button.style.boxSizing = "border-box"; } function applyErrorStyles(error: HTMLElement): void { @@ -743,6 +922,8 @@ function applySecondaryButtonStyles(button: HTMLButtonElement): void { button.style.color = "#334155"; button.style.cursor = "pointer"; button.style.fontSize = "12px"; + button.style.boxSizing = "border-box"; + button.style.maxWidth = "100%"; } function applyPrimaryButtonStyles(button: HTMLButtonElement): void { @@ -754,6 +935,8 @@ function applyPrimaryButtonStyles(button: HTMLButtonElement): void { button.style.color = "#ffffff"; button.style.cursor = "pointer"; button.style.fontSize = "12px"; + button.style.boxSizing = "border-box"; + button.style.maxWidth = "100%"; } function applyIconButtonStyles(button: HTMLButtonElement): void { diff --git a/tests/favorites-drawer.test.ts b/tests/favorites-drawer.test.ts index 4ce58a3..fd76559 100644 --- a/tests/favorites-drawer.test.ts +++ b/tests/favorites-drawer.test.ts @@ -6,6 +6,7 @@ import { createFavoritesDrawer, type FavoritesDrawerHandlers } from "../src/content/market/favorites-drawer"; +import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog"; import type { FavoriteCreator, FavoriteFolder, @@ -107,6 +108,14 @@ async function flush(): Promise { await Promise.resolve(); } +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + describe("favorites-drawer", () => { beforeEach(() => { document.body.innerHTML = ""; @@ -157,6 +166,42 @@ describe("favorites-drawer", () => { expect(document.activeElement).toBe(readTab()); }); + test("uses an accessible icon close control and a wrapping footer for folder imports", () => { + const { controller } = createHarness(); + controller.render(createState()); + readTab().click(); + click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); + + const close = document.querySelector( + '[data-sces-favorites-drawer="close"]' + ) as HTMLButtonElement; + const footer = readDrawer().querySelector("footer") as HTMLElement; + const importFolder = document.querySelector( + '[data-sces-favorites-drawer="import-folder"]' + ) as HTMLButtonElement; + const importSelected = document.querySelector( + '[data-sces-favorites-drawer="import-selected"]' + ) as HTMLButtonElement; + const actionRow = importFolder.parentElement as HTMLElement; + + expect(close.textContent).toBe("x"); + expect(close.getAttribute("aria-label")).toBe("关闭收藏夹"); + expect(close.title).toBe("关闭收藏夹"); + expect(footer.style.boxSizing).toBe("border-box"); + expect(footer.style.minWidth).toBe("0px"); + expect(footer.style.flexWrap).toBe("wrap"); + expect(actionRow.style.boxSizing).toBe("border-box"); + expect(actionRow.style.minWidth).toBe("0px"); + expect(actionRow.style.flexWrap).toBe("wrap"); + expect(actionRow.style.width).toBe("100%"); + expect(importFolder.style.boxSizing).toBe("border-box"); + expect(importFolder.style.minWidth).toBe("0px"); + expect(importFolder.style.flex).toBe("1 1 100%"); + expect(importSelected.style.boxSizing).toBe("border-box"); + expect(importSelected.style.minWidth).toBe("0px"); + expect(importSelected.style.flex).toBe("1 1 100%"); + }); + test("deduplicates creators in all view and shows their folder affiliations", () => { const { controller } = createHarness(); controller.render( @@ -210,6 +255,31 @@ describe("favorites-drawer", () => { ).toBeNull(); }); + test.each([ + { folderId: "all", name: "全部的具体收藏夹" }, + { folderId: "", name: "空 ID 收藏夹" } + ])("treats folder id $folderId as a concrete folder", ({ folderId, name }) => { + const folder = createFolder(folderId, name); + const { controller } = createHarness(); + controller.render( + createState({ + creators: [createCreator("111", "Alpha"), createCreator("222", "Beta")], + folders: [folder], + memberships: [createMembership("111", folderId)] + }) + ); + readTab().click(); + + click(`[data-sces-favorites-folder-id="${folderId}"]`); + + expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); + expect(readDrawer().textContent).toContain("Alpha"); + expect(readDrawer().textContent).not.toContain("Beta"); + expect( + document.querySelector('[data-sces-favorites-drawer="import-folder"]') + ).not.toBeNull(); + }); + test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => { const { controller } = createHarness(); controller.render(createState()); @@ -306,6 +376,114 @@ describe("favorites-drawer", () => { expect(onImportSelected).toHaveBeenCalledWith(["111"]); }); + test("locks duplicate imports and overlapping folder mutations until the active handler settles", async () => { + const selectedImport = createDeferred(); + const deleteFolder = createDeferred(); + const onImportSelected = vi.fn(() => selectedImport.promise); + const onDeleteFolder = vi.fn(() => deleteFolder.promise); + const onRenameFolder = vi.fn(async (_folderId: string) => {}); + const { controller } = createHarness({ + handlers: { onDeleteFolder, onImportSelected, onRenameFolder } + }); + controller.render(createState()); + readTab().click(); + + const checkbox = document.querySelector( + '[data-sces-favorites-select-author-id="author-1"]' + ) as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + click('[data-sces-favorites-drawer="import-selected"]'); + const pendingImport = document.querySelector( + '[data-sces-favorites-drawer="import-selected"]' + ) as HTMLButtonElement; + pendingImport.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + expect(onImportSelected).toHaveBeenCalledTimes(1); + expect(pendingImport.disabled).toBe(true); + + selectedImport.resolve(); + await flush(); + click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); + click(`[data-sces-favorites-folder-delete="${firstFolder.id}"]`); + const pendingRename = document.querySelector( + `[data-sces-favorites-folder-rename="${firstFolder.id}"]` + ) as HTMLButtonElement; + pendingRename.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + expect(onDeleteFolder).toHaveBeenCalledWith(firstFolder.id); + expect(onRenameFolder).not.toHaveBeenCalled(); + expect(pendingRename.disabled).toBe(true); + + deleteFolder.resolve(); + await flush(); + }); + + test("preserves focus for a folder switch and a creator checkbox render", () => { + const { controller } = createHarness(); + controller.render(createState()); + readTab().click(); + + const folder = document.querySelector( + `[data-sces-favorites-folder-id="${firstFolder.id}"]` + ) as HTMLButtonElement; + folder.focus(); + folder.click(); + const activeFolder = document.querySelector( + `[data-sces-favorites-folder-id="${firstFolder.id}"]` + ) as HTMLButtonElement; + expect(document.activeElement).toBe(activeFolder); + + const checkbox = document.querySelector( + '[data-sces-favorites-select-author-id="author-1"]' + ) as HTMLInputElement; + checkbox.focus(); + checkbox.checked = true; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + const activeCheckbox = document.querySelector( + '[data-sces-favorites-select-author-id="author-1"]' + ) as HTMLInputElement; + expect(document.activeElement).toBe(activeCheckbox); + }); + + test("leaves the drawer open when an active folder-name dialog handles Escape", async () => { + let dialogResult: Promise | undefined; + const onCreateFolder = vi.fn(async () => { + dialogResult = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + await dialogResult; + }); + const { controller } = createHarness({ handlers: { onCreateFolder } }); + controller.render(createState()); + readTab().click(); + const search = document.querySelector( + '[data-sces-favorites-drawer="search"]' + ) as HTMLInputElement; + search.value = "Alpha"; + search.dispatchEvent(new Event("input", { bubbles: true })); + + click('[data-sces-favorites-drawer="create-folder"]'); + await flush(); + expect( + document.querySelector('[data-sces-favorite-folder-dialog="root"]') + ).not.toBeNull(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await expect(dialogResult).resolves.toBeNull(); + await flush(); + + const restoredSearch = document.querySelector( + '[data-sces-favorites-drawer="search"]' + ) as HTMLInputElement; + expect(readDrawer().hidden).toBe(false); + expect(readTab().getAttribute("aria-expanded")).toBe("true"); + expect(restoredSearch.value).toBe("Alpha"); + expect(document.activeElement).toBe(restoredSearch); + }); + test("calls create, rename, and delete handlers with the matching folder id", async () => { const onCreateFolder = vi.fn(async () => {}); const onRenameFolder = vi.fn(async (_folderId: string) => {}); @@ -317,7 +495,9 @@ describe("favorites-drawer", () => { readTab().click(); click('[data-sces-favorites-drawer="create-folder"]'); + await flush(); click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`); + await flush(); click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`); await flush(); @@ -412,7 +592,11 @@ describe("favorites-drawer", () => { controller.dispose(); expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull(); expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull(); - expect(removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function)); + expect(removeEventListener).toHaveBeenCalledWith( + "keydown", + expect.any(Function), + true + ); const unrelatedButton = document.createElement("button"); unrelatedButton.type = "button";