From 2f2a590df497742ab23137ef49293cb9fc0b5392 Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 17 Jul 2026 12:56:19 +0800 Subject: [PATCH] feat: add favorites drawer management --- src/content/market/favorite-folder-dialog.ts | 250 ++++++ src/content/market/favorites-drawer.ts | 769 +++++++++++++++++++ tests/favorite-folder-dialog.test.ts | 170 ++++ tests/favorites-drawer.test.ts | 369 +++++++++ 4 files changed, 1558 insertions(+) create mode 100644 src/content/market/favorite-folder-dialog.ts create mode 100644 src/content/market/favorites-drawer.ts create mode 100644 tests/favorite-folder-dialog.test.ts create mode 100644 tests/favorites-drawer.test.ts diff --git a/src/content/market/favorite-folder-dialog.ts b/src/content/market/favorite-folder-dialog.ts new file mode 100644 index 0000000..d3270e8 --- /dev/null +++ b/src/content/market/favorite-folder-dialog.ts @@ -0,0 +1,250 @@ +const activeDialogs = new WeakMap< + Document, + { + input: HTMLInputElement; + promise: Promise; + } +>(); + +export function promptForFavoriteFolderName( + document: Document, + options: { + initialValue?: string; + title: "新建收藏夹" | "重命名收藏夹"; + } +): Promise { + const activeDialog = activeDialogs.get(document); + if (activeDialog) { + activeDialog.input.focus(); + activeDialog.input.select(); + return activeDialog.promise; + } + + const previousFocusedElement = document.activeElement; + + const root = document.createElement("div"); + root.dataset.scesFavoriteFolderDialog = "root"; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-modal", "true"); + root.setAttribute("aria-labelledby", "sces-favorite-folder-dialog-title"); + applyOverlayStyles(root); + + const panel = document.createElement("section"); + applyPanelStyles(panel); + + const title = document.createElement("h2"); + title.id = "sces-favorite-folder-dialog-title"; + title.textContent = options.title; + applyTitleStyles(title); + + const input = document.createElement("input"); + input.type = "text"; + input.value = options.initialValue ?? ""; + input.maxLength = 60; + input.placeholder = "请输入收藏夹名称"; + input.dataset.scesFavoriteFolderDialog = "input"; + input.setAttribute("aria-label", "收藏夹名称"); + applyInputStyles(input); + + const error = document.createElement("p"); + error.dataset.scesFavoriteFolderDialog = "error"; + error.setAttribute("aria-live", "polite"); + applyErrorStyles(error); + + const actions = document.createElement("div"); + applyActionStyles(actions); + + const cancelButton = createButton(document, "取消"); + cancelButton.dataset.scesFavoriteFolderDialog = "cancel"; + applySecondaryButtonStyles(cancelButton); + + const confirmButton = createButton(document, "确认"); + confirmButton.dataset.scesFavoriteFolderDialog = "confirm"; + applyPrimaryButtonStyles(confirmButton); + + actions.append(cancelButton, confirmButton); + panel.append(title, input, error, actions); + root.append(panel); + (document.body ?? document.documentElement).append(root); + + let closeDialog: (value: string | null) => void = () => {}; + const promise = new Promise((resolve) => { + let closed = false; + + const clearError = () => { + if (!input.value.trim()) { + return; + } + error.textContent = ""; + input.removeAttribute("aria-invalid"); + }; + + const submit = () => { + const value = input.value.trim(); + if (!value) { + error.textContent = "请输入收藏夹名称"; + input.setAttribute("aria-invalid", "true"); + input.focus(); + return; + } + closeDialog(value); + }; + + const onKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeDialog(null); + return; + } + if (event.key === "Tab") { + const focusableElements = Array.from( + root.querySelectorAll("button:not(:disabled), input:not(:disabled)") + ); + const activeIndex = focusableElements.indexOf( + document.activeElement as HTMLElement + ); + const nextIndex = event.shiftKey + ? activeIndex <= 0 + ? focusableElements.length - 1 + : activeIndex - 1 + : activeIndex === focusableElements.length - 1 + ? 0 + : activeIndex + 1; + const nextElement = focusableElements[nextIndex]; + if (nextElement) { + event.preventDefault(); + nextElement.focus(); + } + return; + } + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } + }; + + const onOverlayClick = (event: MouseEvent) => { + if (event.target === root) { + closeDialog(null); + } + }; + + const onCancel = () => closeDialog(null); + const onConfirm = () => submit(); + + closeDialog = (value) => { + if (closed) { + return; + } + closed = true; + activeDialogs.delete(document); + input.removeEventListener("input", clearError); + cancelButton.removeEventListener("click", onCancel); + confirmButton.removeEventListener("click", onConfirm); + root.removeEventListener("click", onOverlayClick); + document.removeEventListener("keydown", onKeydown, true); + root.remove(); + if ( + previousFocusedElement instanceof HTMLElement && + previousFocusedElement.isConnected + ) { + previousFocusedElement.focus(); + } + resolve(value); + }; + + input.addEventListener("input", clearError); + cancelButton.addEventListener("click", onCancel); + confirmButton.addEventListener("click", onConfirm); + root.addEventListener("click", onOverlayClick); + document.addEventListener("keydown", onKeydown, true); + }); + + activeDialogs.set(document, { input, promise }); + input.focus(); + return promise; +} + +function createButton(document: Document, label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; +} + +function applyOverlayStyles(root: HTMLElement): void { + root.style.position = "fixed"; + root.style.inset = "0"; + root.style.zIndex = "2147483647"; + root.style.display = "flex"; + root.style.alignItems = "center"; + root.style.justifyContent = "center"; + root.style.padding = "24px"; + root.style.boxSizing = "border-box"; + root.style.background = "rgba(15, 23, 42, 0.38)"; +} + +function applyPanelStyles(panel: HTMLElement): void { + panel.style.width = "min(420px, calc(100vw - 32px))"; + panel.style.padding = "20px"; + panel.style.boxSizing = "border-box"; + panel.style.border = "1px solid #d7dde6"; + panel.style.borderRadius = "8px"; + panel.style.background = "#ffffff"; + panel.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; +} + +function applyTitleStyles(title: HTMLElement): void { + title.style.margin = "0"; + title.style.color = "#172033"; + title.style.fontSize = "18px"; + title.style.lineHeight = "26px"; + title.style.fontWeight = "700"; +} + +function applyInputStyles(input: HTMLInputElement): void { + input.style.width = "100%"; + input.style.height = "40px"; + input.style.marginTop = "18px"; + input.style.padding = "0 12px"; + input.style.boxSizing = "border-box"; + input.style.border = "1px solid #b9c4d3"; + input.style.borderRadius = "6px"; + input.style.color = "#172033"; + input.style.fontSize = "14px"; +} + +function applyErrorStyles(error: HTMLElement): void { + error.style.minHeight = "18px"; + error.style.margin = "6px 0 0"; + error.style.color = "#b42318"; + error.style.fontSize = "12px"; + error.style.lineHeight = "18px"; +} + +function applyActionStyles(actions: HTMLElement): void { + actions.style.display = "flex"; + actions.style.justifyContent = "flex-end"; + actions.style.gap = "8px"; + actions.style.marginTop = "16px"; +} + +function applySecondaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "34px"; + button.style.padding = "0 14px"; + button.style.border = "1px solid #b9c4d3"; + button.style.borderRadius = "6px"; + button.style.background = "#ffffff"; + button.style.color = "#334155"; + button.style.cursor = "pointer"; +} + +function applyPrimaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "34px"; + button.style.padding = "0 14px"; + button.style.border = "1px solid #0f766e"; + button.style.borderRadius = "6px"; + button.style.background = "#0f766e"; + button.style.color = "#ffffff"; + button.style.cursor = "pointer"; +} diff --git a/src/content/market/favorites-drawer.ts b/src/content/market/favorites-drawer.ts new file mode 100644 index 0000000..501764d --- /dev/null +++ b/src/content/market/favorites-drawer.ts @@ -0,0 +1,769 @@ +import type { + FavoriteCreator, + FavoritesStateV1 +} from "./favorites-store"; + +export interface FavoritesDrawerHandlers { + onCreateFolder(): Promise; + onDeleteFolder(folderId: string): Promise; + onImportFolder(folderId: string, authorIds: string[]): Promise; + onImportSelected(authorIds: string[]): Promise; + onRemoveCreatorFromFolder(authorId: string, folderId: string): Promise; + onRenameFolder(folderId: string): Promise; +} + +export interface FavoritesDrawerController { + dispose(): void; + render(state: FavoritesStateV1): void; + setBusy(isBusy: boolean): void; + setError(message: string): void; +} + +const DRAWER_ID = "sces-favorites-drawer"; +const activeDrawers = new WeakMap(); + +export function createFavoritesDrawer( + document: Document, + handlers: FavoritesDrawerHandlers, + confirmImport: (message: string) => boolean +): FavoritesDrawerController { + activeDrawers.get(document)?.dispose(); + document + .querySelectorAll( + '[data-sces-favorites-tab="button"], [data-sces-favorites-drawer="root"]' + ) + .forEach((element) => element.remove()); + + const tab = createButton(document, "收藏夹"); + tab.dataset.scesFavoritesTab = "button"; + tab.setAttribute("aria-controls", DRAWER_ID); + tab.setAttribute("aria-expanded", "false"); + tab.setAttribute("aria-haspopup", "dialog"); + applyTabStyles(tab); + + const drawer = document.createElement("aside"); + drawer.id = DRAWER_ID; + drawer.dataset.scesFavoritesDrawer = "root"; + drawer.hidden = true; + drawer.tabIndex = -1; + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-label", "收藏夹"); + drawer.setAttribute("aria-modal", "false"); + drawer.setAttribute("aria-hidden", "true"); + applyDrawerStyles(drawer); + + (document.body ?? document.documentElement).append(tab, drawer); + + let busy = false; + let disposed = false; + let errorMessage = ""; + let isOpen = false; + let searchQuery = ""; + let state = emptyState(); + let view: "all" | string = "all"; + let selectedAuthorIds = new Set(); + + const controller: FavoritesDrawerController = { + dispose, + render(nextState) { + if (disposed) { + return; + } + state = nextState; + renderDrawer(); + }, + setBusy(isBusy) { + if (disposed) { + return; + } + busy = isBusy; + renderDrawer(); + }, + setError(message) { + if (disposed) { + return; + } + errorMessage = message; + renderDrawer(); + } + }; + + const onTabClick = () => { + if (isOpen) { + closeDrawer(); + } else { + openDrawer(); + } + }; + + const onDocumentKeydown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || !isOpen) { + return; + } + event.preventDefault(); + closeDrawer(); + }; + + const onDrawerClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const button = target.closest("button"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + + if (button.dataset.scesFavoritesDrawer === "close") { + closeDrawer(); + return; + } + if (button.dataset.scesFavoritesDrawer === "view-all") { + view = "all"; + renderDrawer(); + return; + } + + const folderId = button.dataset.scesFavoritesFolderId; + if (folderId) { + view = folderId; + renderDrawer(); + return; + } + if (busy) { + return; + } + + if (button.dataset.scesFavoritesDrawer === "create-folder") { + invokeHandler(handlers.onCreateFolder); + return; + } + + const renameFolderId = button.dataset.scesFavoritesFolderRename; + if (renameFolderId) { + invokeHandler(() => handlers.onRenameFolder(renameFolderId)); + return; + } + + const deleteFolderId = button.dataset.scesFavoritesFolderDelete; + if (deleteFolderId) { + invokeHandler(() => handlers.onDeleteFolder(deleteFolderId)); + return; + } + + const removeAuthorId = button.dataset.scesFavoritesRemoveAuthorId; + if (removeAuthorId && view !== "all") { + invokeHandler(() => handlers.onRemoveCreatorFromFolder(removeAuthorId, view)); + return; + } + + if (button.dataset.scesFavoritesDrawer === "import-selected") { + const authorIds = selectedCreatorsInCurrentView().map((creator) => creator.authorId); + if (authorIds.length > 0) { + invokeHandler(() => handlers.onImportSelected(authorIds)); + } + return; + } + + if (button.dataset.scesFavoritesDrawer === "import-folder" && view !== "all") { + const authorIds = creatorsInCurrentView().map((creator) => creator.authorId); + const message = `将当前收藏夹的 ${authorIds.length} 位达人导入秒探吗?`; + if (confirmImport(message)) { + invokeHandler(() => handlers.onImportFolder(view, authorIds)); + } + } + }; + + const onDrawerInput = (event: Event) => { + const target = event.target; + if ( + target instanceof HTMLInputElement && + target.dataset.scesFavoritesDrawer === "search" + ) { + searchQuery = target.value; + const selectionStart = target.selectionStart; + const selectionEnd = target.selectionEnd; + 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 || + !(target instanceof HTMLInputElement) || + target.type !== "checkbox" + ) { + return; + } + const authorId = target.dataset.scesFavoritesSelectAuthorId; + if (!authorId) { + return; + } + if (target.checked) { + selectedAuthorIds.add(authorId); + } else { + selectedAuthorIds.delete(authorId); + } + renderDrawer(); + }; + + tab.addEventListener("click", onTabClick); + drawer.addEventListener("click", onDrawerClick); + drawer.addEventListener("input", onDrawerInput); + drawer.addEventListener("change", onDrawerChange); + document.addEventListener("keydown", onDocumentKeydown); + renderDrawer(); + activeDrawers.set(document, controller); + + return controller; + + function openDrawer(): void { + if (disposed) { + return; + } + isOpen = true; + drawer.hidden = false; + drawer.setAttribute("aria-hidden", "false"); + tab.setAttribute("aria-expanded", "true"); + renderDrawer(); + const closeButton = drawer.querySelector( + '[data-sces-favorites-drawer="close"]' + ); + if (closeButton instanceof HTMLButtonElement) { + closeButton.focus(); + } else { + drawer.focus(); + } + } + + function closeDrawer(): void { + if (disposed || !isOpen) { + return; + } + isOpen = false; + drawer.hidden = true; + drawer.setAttribute("aria-hidden", "true"); + tab.setAttribute("aria-expanded", "false"); + if (tab.isConnected) { + tab.focus(); + } + } + + function renderDrawer(): void { + if (view !== "all" && !state.folders.some((folder) => folder.id === view)) { + view = "all"; + } + + const currentCreators = creatorsInCurrentView(); + const currentAuthorIds = new Set(currentCreators.map((creator) => creator.authorId)); + selectedAuthorIds = new Set( + Array.from(selectedAuthorIds).filter((authorId) => currentAuthorIds.has(authorId)) + ); + + const query = searchQuery.trim().toLocaleLowerCase(); + const visibleCreators = currentCreators.filter((creator) => + creator.authorName.toLocaleLowerCase().includes(query) + ); + const selectedCount = selectedCreatorsInCurrentView().length; + + const fragment = document.createDocumentFragment(); + fragment.append(createHeader()); + if (errorMessage) { + const error = document.createElement("p"); + error.dataset.scesFavoritesDrawer = "error"; + error.textContent = errorMessage; + applyErrorStyles(error); + fragment.append(error); + } + fragment.append(createFolderNavigation()); + fragment.append(createSearch()); + fragment.append(createCreatorList(visibleCreators)); + fragment.append(createFooter(selectedCount)); + drawer.replaceChildren(fragment); + } + + function createHeader(): HTMLElement { + const header = document.createElement("header"); + applyHeaderStyles(header); + + const title = document.createElement("h2"); + title.textContent = "收藏夹"; + applyTitleStyles(title); + + const actions = document.createElement("div"); + applyHeaderActionsStyles(actions); + + const createFolder = createButton(document, "新建收藏夹"); + createFolder.dataset.scesFavoritesDrawer = "create-folder"; + createFolder.disabled = busy; + applySecondaryButtonStyles(createFolder); + + const close = createButton(document, "关闭收藏夹"); + close.dataset.scesFavoritesDrawer = "close"; + close.setAttribute("aria-label", "关闭收藏夹"); + applyIconButtonStyles(close); + + actions.append(createFolder, close); + header.append(title, actions); + return header; + } + + function createFolderNavigation(): HTMLElement { + const navigation = document.createElement("nav"); + navigation.setAttribute("aria-label", "收藏夹列表"); + applyNavigationStyles(navigation); + + const allButton = createButton(document, "全部收藏"); + allButton.dataset.scesFavoritesDrawer = "view-all"; + allButton.setAttribute("aria-current", String(view === "all")); + applyViewButtonStyles(allButton, view === "all"); + navigation.append(allButton); + + state.folders.forEach((folder) => { + const row = document.createElement("div"); + applyFolderRowStyles(row); + + 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 rename = createButton(document, "重命名"); + rename.dataset.scesFavoritesFolderRename = folder.id; + rename.setAttribute("aria-label", `重命名收藏夹 ${folder.name}`); + rename.disabled = busy; + applyTextActionStyles(rename); + + const remove = createButton(document, "删除"); + remove.dataset.scesFavoritesFolderDelete = folder.id; + remove.setAttribute("aria-label", `删除收藏夹 ${folder.name}`); + remove.disabled = busy; + applyDangerActionStyles(remove); + + row.append(viewButton, rename, remove); + navigation.append(row); + }); + return navigation; + } + + function createSearch(): HTMLInputElement { + const search = document.createElement("input"); + search.type = "search"; + search.value = searchQuery; + search.placeholder = "搜索达人名称"; + search.dataset.scesFavoritesDrawer = "search"; + search.setAttribute("aria-label", "搜索达人名称"); + applySearchStyles(search); + return search; + } + + function createCreatorList(creators: FavoriteCreator[]): HTMLElement { + const list = document.createElement("section"); + list.dataset.scesFavoritesDrawer = "creators"; + list.setAttribute("aria-label", "收藏达人"); + applyCreatorListStyles(list); + + if (creators.length === 0) { + const empty = document.createElement("p"); + empty.dataset.scesFavoritesDrawer = "empty"; + empty.textContent = "暂无收藏达人"; + applyEmptyStyles(empty); + list.append(empty); + return list; + } + + creators.forEach((creator) => { + const row = document.createElement("article"); + row.dataset.scesFavoritesAuthorId = creator.authorId; + applyCreatorRowStyles(row); + + const selectLabel = document.createElement("label"); + applySelectLabelStyles(selectLabel); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedAuthorIds.has(creator.authorId); + checkbox.disabled = busy; + checkbox.dataset.scesFavoritesSelectAuthorId = creator.authorId; + checkbox.setAttribute("aria-label", `选择达人 ${creator.authorName}`); + selectLabel.append(checkbox); + + const details = document.createElement("div"); + details.style.minWidth = "0"; + details.style.flex = "1"; + const name = document.createElement("strong"); + name.textContent = creator.authorName; + applyCreatorNameStyles(name); + details.append(name); + + if (view === "all") { + const affiliation = document.createElement("p"); + affiliation.textContent = `所属:${folderNamesForCreator(creator.authorId).join("、") || "未分类"}`; + applyAffiliationStyles(affiliation); + details.append(affiliation); + } + + row.append(selectLabel, details); + if (view !== "all") { + const remove = createButton(document, "移出收藏夹"); + remove.dataset.scesFavoritesRemoveAuthorId = creator.authorId; + remove.setAttribute("aria-label", `将 ${creator.authorName} 移出当前收藏夹`); + remove.disabled = busy; + applyDangerActionStyles(remove); + row.append(remove); + } + list.append(row); + }); + return list; + } + + function createFooter(selectedCount: number): HTMLElement { + const footer = document.createElement("footer"); + applyFooterStyles(footer); + + const selected = document.createElement("span"); + selected.textContent = `已选择 ${selectedCount} 位达人`; + applySelectedCountStyles(selected); + + const actions = document.createElement("div"); + applyFooterActionsStyles(actions); + if (view !== "all") { + const importFolder = createButton(document, "导入当前收藏夹全部达人"); + importFolder.dataset.scesFavoritesDrawer = "import-folder"; + importFolder.disabled = busy; + applySecondaryButtonStyles(importFolder); + actions.append(importFolder); + } + + const importSelected = createButton(document, `导入已选 ${selectedCount} 位达人`); + importSelected.dataset.scesFavoritesDrawer = "import-selected"; + importSelected.disabled = busy || selectedCount === 0; + applyPrimaryButtonStyles(importSelected); + actions.append(importSelected); + + footer.append(selected, actions); + return footer; + } + + function creatorsInCurrentView(): FavoriteCreator[] { + const uniqueCreators = new Map(); + if (view === "all") { + state.creators.forEach((creator) => { + if (!uniqueCreators.has(creator.authorId)) { + uniqueCreators.set(creator.authorId, creator); + } + }); + return Array.from(uniqueCreators.values()); + } + + const creatorsById = new Map(); + state.creators.forEach((creator) => { + if (!creatorsById.has(creator.authorId)) { + creatorsById.set(creator.authorId, creator); + } + }); + state.memberships.forEach((membership) => { + if (membership.folderId !== view || uniqueCreators.has(membership.authorId)) { + return; + } + const creator = creatorsById.get(membership.authorId); + if (creator) { + uniqueCreators.set(creator.authorId, creator); + } + }); + return Array.from(uniqueCreators.values()); + } + + function selectedCreatorsInCurrentView(): FavoriteCreator[] { + return creatorsInCurrentView().filter((creator) => selectedAuthorIds.has(creator.authorId)); + } + + function folderNamesForCreator(authorId: string): string[] { + const folderNamesById = new Map(state.folders.map((folder) => [folder.id, folder.name])); + const names = new Set(); + state.memberships.forEach((membership) => { + if (membership.authorId !== authorId) { + return; + } + const name = folderNamesById.get(membership.folderId); + if (name) { + names.add(name); + } + }); + return Array.from(names); + } + + function invokeHandler(handler: () => Promise): void { + if (busy || disposed) { + return; + } + void handler().catch((error: unknown) => { + errorMessage = error instanceof Error ? error.message : "操作失败"; + renderDrawer(); + }); + } + + function dispose(): void { + if (disposed) { + return; + } + disposed = true; + tab.removeEventListener("click", onTabClick); + drawer.removeEventListener("click", onDrawerClick); + drawer.removeEventListener("input", onDrawerInput); + drawer.removeEventListener("change", onDrawerChange); + document.removeEventListener("keydown", onDocumentKeydown); + tab.remove(); + drawer.remove(); + if (activeDrawers.get(document) === controller) { + activeDrawers.delete(document); + } + } +} + +function emptyState(): FavoritesStateV1 { + return { creators: [], folders: [], memberships: [], version: 1 }; +} + +function createButton(document: Document, label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; +} + +function applyTabStyles(tab: HTMLButtonElement): void { + tab.style.position = "fixed"; + tab.style.top = "50%"; + tab.style.right = "56px"; + tab.style.zIndex = "2147483645"; + tab.style.padding = "12px 8px"; + tab.style.border = "1px solid #0f766e"; + tab.style.borderRadius = "6px 0 0 6px"; + tab.style.background = "#ffffff"; + tab.style.color = "#0f766e"; + tab.style.boxShadow = "0 4px 14px rgba(15, 23, 42, 0.14)"; + tab.style.cursor = "pointer"; + tab.style.fontWeight = "700"; + tab.style.writingMode = "vertical-rl"; +} + +function applyDrawerStyles(drawer: HTMLElement): void { + drawer.style.position = "fixed"; + drawer.style.top = "64px"; + drawer.style.right = "56px"; + drawer.style.bottom = "24px"; + drawer.style.zIndex = "2147483646"; + drawer.style.display = "flex"; + drawer.style.flexDirection = "column"; + drawer.style.width = "380px"; + drawer.style.maxWidth = "calc(100vw - 72px)"; + drawer.style.boxSizing = "border-box"; + drawer.style.overflow = "hidden"; + drawer.style.border = "1px solid #d7dde6"; + drawer.style.borderRadius = "8px"; + drawer.style.background = "#ffffff"; + drawer.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.2)"; + drawer.style.color = "#172033"; + drawer.style.fontFamily = "Arial, sans-serif"; +} + +function applyHeaderStyles(header: HTMLElement): void { + header.style.display = "flex"; + header.style.alignItems = "center"; + header.style.justifyContent = "space-between"; + header.style.gap = "12px"; + header.style.padding = "16px"; + header.style.borderBottom = "1px solid #e2e8f0"; +} + +function applyTitleStyles(title: HTMLElement): void { + title.style.margin = "0"; + title.style.fontSize = "18px"; + title.style.lineHeight = "24px"; + title.style.fontWeight = "700"; +} + +function applyHeaderActionsStyles(actions: HTMLElement): void { + actions.style.display = "flex"; + actions.style.alignItems = "center"; + actions.style.gap = "8px"; +} + +function applyNavigationStyles(navigation: HTMLElement): void { + navigation.style.display = "flex"; + navigation.style.flexDirection = "column"; + navigation.style.gap = "4px"; + navigation.style.padding = "12px 16px"; + navigation.style.borderBottom = "1px solid #e2e8f0"; +} + +function applyFolderRowStyles(row: HTMLElement): void { + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.gap = "4px"; +} + +function applyViewButtonStyles(button: HTMLButtonElement, active: boolean): void { + button.style.minWidth = "0"; + button.style.flex = "1"; + button.style.height = "30px"; + button.style.padding = "0 8px"; + button.style.border = "0"; + button.style.borderRadius = "4px"; + button.style.background = active ? "#e0f2f1" : "transparent"; + button.style.color = active ? "#0f766e" : "#334155"; + button.style.cursor = "pointer"; + button.style.fontSize = "13px"; + button.style.overflow = "hidden"; + button.style.textAlign = "left"; + button.style.textOverflow = "ellipsis"; + button.style.whiteSpace = "nowrap"; +} + +function applyTextActionStyles(button: HTMLButtonElement): void { + button.style.height = "26px"; + button.style.padding = "0 4px"; + button.style.border = "0"; + button.style.background = "transparent"; + button.style.color = "#475569"; + button.style.cursor = "pointer"; + button.style.fontSize = "12px"; +} + +function applyDangerActionStyles(button: HTMLButtonElement): void { + applyTextActionStyles(button); + button.style.color = "#b42318"; +} + +function applySearchStyles(search: HTMLInputElement): void { + search.style.height = "36px"; + search.style.margin = "12px 16px"; + search.style.padding = "0 10px"; + search.style.border = "1px solid #cbd5e1"; + search.style.borderRadius = "6px"; + search.style.fontSize = "13px"; +} + +function applyCreatorListStyles(list: HTMLElement): void { + list.style.display = "flex"; + list.style.flex = "1"; + list.style.flexDirection = "column"; + list.style.gap = "8px"; + list.style.minHeight = "0"; + list.style.padding = "0 16px 16px"; + list.style.overflowY = "auto"; +} + +function applyCreatorRowStyles(row: HTMLElement): void { + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.gap = "10px"; + row.style.padding = "10px"; + row.style.border = "1px solid #e2e8f0"; + row.style.borderRadius = "6px"; +} + +function applySelectLabelStyles(label: HTMLElement): void { + label.style.display = "flex"; + label.style.alignItems = "center"; +} + +function applyCreatorNameStyles(name: HTMLElement): void { + name.style.display = "block"; + name.style.overflow = "hidden"; + name.style.fontSize = "14px"; + name.style.lineHeight = "20px"; + name.style.textOverflow = "ellipsis"; + name.style.whiteSpace = "nowrap"; +} + +function applyAffiliationStyles(affiliation: HTMLElement): void { + affiliation.style.margin = "2px 0 0"; + affiliation.style.color = "#64748b"; + affiliation.style.fontSize = "12px"; + affiliation.style.lineHeight = "18px"; +} + +function applyEmptyStyles(empty: HTMLElement): void { + empty.style.margin = "24px 0"; + empty.style.color = "#64748b"; + empty.style.fontSize = "13px"; + empty.style.textAlign = "center"; +} + +function applyFooterStyles(footer: HTMLElement): void { + footer.style.display = "flex"; + footer.style.alignItems = "center"; + footer.style.justifyContent = "space-between"; + footer.style.gap = "12px"; + footer.style.padding = "12px 16px"; + footer.style.borderTop = "1px solid #e2e8f0"; +} + +function applySelectedCountStyles(selected: HTMLElement): void { + selected.style.flex = "0 0 auto"; + selected.style.color = "#475569"; + selected.style.fontSize = "12px"; +} + +function applyFooterActionsStyles(actions: HTMLElement): void { + actions.style.display = "flex"; + actions.style.alignItems = "center"; + actions.style.justifyContent = "flex-end"; + actions.style.gap = "8px"; + actions.style.minWidth = "0"; +} + +function applyErrorStyles(error: HTMLElement): void { + error.style.margin = "0"; + error.style.padding = "8px 16px"; + error.style.background = "#fef2f2"; + error.style.color = "#b42318"; + error.style.fontSize = "12px"; + error.style.lineHeight = "18px"; +} + +function applySecondaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "30px"; + button.style.padding = "0 8px"; + button.style.border = "1px solid #b9c4d3"; + button.style.borderRadius = "5px"; + button.style.background = "#ffffff"; + button.style.color = "#334155"; + button.style.cursor = "pointer"; + button.style.fontSize = "12px"; +} + +function applyPrimaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "30px"; + button.style.padding = "0 8px"; + button.style.border = "1px solid #0f766e"; + button.style.borderRadius = "5px"; + button.style.background = "#0f766e"; + button.style.color = "#ffffff"; + button.style.cursor = "pointer"; + button.style.fontSize = "12px"; +} + +function applyIconButtonStyles(button: HTMLButtonElement): void { + button.style.width = "30px"; + button.style.height = "30px"; + button.style.padding = "0"; + button.style.border = "1px solid #b9c4d3"; + button.style.borderRadius = "5px"; + button.style.background = "#ffffff"; + button.style.color = "#334155"; + button.style.cursor = "pointer"; + button.style.fontSize = "12px"; +} diff --git a/tests/favorite-folder-dialog.test.ts b/tests/favorite-folder-dialog.test.ts new file mode 100644 index 0000000..e76461f --- /dev/null +++ b/tests/favorite-folder-dialog.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, test } from "vitest"; + +import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog"; + +function readDialog(): HTMLElement { + const dialog = document.querySelector( + '[data-sces-favorite-folder-dialog="root"]' + ); + if (!(dialog instanceof HTMLElement)) { + throw new Error("Expected favorite folder dialog"); + } + return dialog; +} + +function readInput(): HTMLInputElement { + const input = document.querySelector( + '[data-sces-favorite-folder-dialog="input"]' + ); + if (!(input instanceof HTMLInputElement)) { + throw new Error("Expected favorite folder name input"); + } + return input; +} + +function readButton(name: "confirm" | "cancel"): HTMLButtonElement { + const button = document.querySelector( + `[data-sces-favorite-folder-dialog="${name}"]` + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${name} button`); + } + return button; +} + +describe("favorite-folder-dialog", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + test("renders the requested title and initial name, then trims a confirmation", async () => { + const result = promptForFavoriteFolderName(document, { + initialValue: " 重点合作 ", + title: "重命名收藏夹" + }); + + expect(readDialog().getAttribute("role")).toBe("dialog"); + expect(readDialog().textContent).toContain("重命名收藏夹"); + expect(readInput().value).toBe(" 重点合作 "); + expect(document.activeElement).toBe(readInput()); + + readButton("confirm").click(); + + await expect(result).resolves.toBe("重点合作"); + expect( + document.querySelector('[data-sces-favorite-folder-dialog="root"]') + ).toBeNull(); + }); + + test("keeps an inline error open for blank names and clears it after input", async () => { + const result = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + + readInput().value = " "; + readButton("confirm").click(); + + expect( + document.querySelector('[data-sces-favorite-folder-dialog="error"]')?.textContent + ).toBe("请输入收藏夹名称"); + expect(readInput().getAttribute("aria-invalid")).toBe("true"); + expect(document.activeElement).toBe(readInput()); + + readInput().value = "重点"; + readInput().dispatchEvent(new Event("input", { bubbles: true })); + expect( + document.querySelector('[data-sces-favorite-folder-dialog="error"]')?.textContent + ).toBe(""); + + readButton("cancel").click(); + await expect(result).resolves.toBeNull(); + }); + + test("cancels from its cancel button, overlay click, and Escape", async () => { + const fromCancel = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + readButton("cancel").click(); + await expect(fromCancel).resolves.toBeNull(); + + const fromOverlay = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + readDialog().dispatchEvent(new MouseEvent("click", { bubbles: true })); + await expect(fromOverlay).resolves.toBeNull(); + + const fromEscape = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await expect(fromEscape).resolves.toBeNull(); + }); + + test("confirms a valid name with Enter", async () => { + const result = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + readInput().value = "待联系"; + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + + await expect(result).resolves.toBe("待联系"); + }); + + test("reuses and focuses one active dialog per document, then allows a new one after close", async () => { + const first = promptForFavoriteFolderName(document, { + initialValue: "首个值", + title: "新建收藏夹" + }); + const input = readInput(); + input.blur(); + + const reused = promptForFavoriteFolderName(document, { + initialValue: "不会替换", + title: "重命名收藏夹" + }); + + expect(reused).toBe(first); + expect( + document.querySelectorAll('[data-sces-favorite-folder-dialog="root"]') + ).toHaveLength(1); + expect(readInput().value).toBe("首个值"); + expect(document.activeElement).toBe(input); + + readButton("cancel").click(); + await expect(first).resolves.toBeNull(); + + const second = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + expect( + document.querySelectorAll('[data-sces-favorite-folder-dialog="root"]') + ).toHaveLength(1); + readButton("cancel").click(); + await expect(second).resolves.toBeNull(); + }); + + test("traps Tab within the dialog and restores the opener focus on close", async () => { + const opener = document.createElement("button"); + opener.type = "button"; + opener.textContent = "打开收藏夹"; + document.body.append(opener); + opener.focus(); + + const result = promptForFavoriteFolderName(document, { + title: "新建收藏夹" + }); + + readInput().focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true })); + expect(document.activeElement).toBe(readButton("confirm")); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + expect(document.activeElement).toBe(readInput()); + + readButton("cancel").click(); + await expect(result).resolves.toBeNull(); + expect(document.activeElement).toBe(opener); + }); +}); diff --git a/tests/favorites-drawer.test.ts b/tests/favorites-drawer.test.ts new file mode 100644 index 0000000..9f6474c --- /dev/null +++ b/tests/favorites-drawer.test.ts @@ -0,0 +1,369 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + createFavoritesDrawer, + type FavoritesDrawerHandlers +} from "../src/content/market/favorites-drawer"; +import type { + FavoriteCreator, + FavoriteFolder, + FavoriteMembership, + FavoritesStateV1 +} from "../src/content/market/favorites-store"; + +const firstFolder = createFolder("folder-1", "合作"); +const secondFolder = createFolder("folder-2", "待联系"); + +function createFolder(id: string, name: string): FavoriteFolder { + return { + createdAt: "2026-07-17T00:00:00.000Z", + id, + name, + updatedAt: "2026-07-17T00:00:00.000Z" + }; +} + +function createCreator(authorId: string, authorName: string): FavoriteCreator { + return { + authorId, + authorName, + savedAt: "2026-07-17T00:00:00.000Z" + }; +} + +function createMembership(authorId: string, folderId: string): FavoriteMembership { + return { + addedAt: "2026-07-17T00:00:00.000Z", + authorId, + folderId + }; +} + +function createState(options: { + creators?: FavoriteCreator[]; + folders?: FavoriteFolder[]; + memberships?: FavoriteMembership[]; +} = {}): FavoritesStateV1 { + return { + creators: + options.creators ?? [createCreator("author-1", "Alpha"), createCreator("author-2", "Beta")], + folders: options.folders ?? [firstFolder, secondFolder], + memberships: + options.memberships ?? [ + createMembership("author-1", firstFolder.id), + createMembership("author-2", secondFolder.id) + ], + version: 1 + }; +} + +function createHarness(options: { + confirmImport?: (message: string) => boolean; + handlers?: Partial; +} = {}) { + const handlers: FavoritesDrawerHandlers = { + onCreateFolder: async () => {}, + onDeleteFolder: async () => {}, + onImportFolder: async () => {}, + onImportSelected: async () => {}, + onRemoveCreatorFromFolder: async () => {}, + onRenameFolder: async () => {}, + ...options.handlers + }; + const confirmImport = options.confirmImport ?? vi.fn(() => true); + const controller = createFavoritesDrawer(document, handlers, confirmImport); + + return { confirmImport, controller, handlers }; +} + +function readTab(): HTMLButtonElement { + const tab = document.querySelector('[data-sces-favorites-tab="button"]'); + if (!(tab instanceof HTMLButtonElement)) { + throw new Error("Expected favorites tab"); + } + return tab; +} + +function readDrawer(): HTMLElement { + const drawer = document.querySelector('[data-sces-favorites-drawer="root"]'); + if (!(drawer instanceof HTMLElement)) { + throw new Error("Expected favorites drawer"); + } + return drawer; +} + +function click(selector: string): void { + const target = document.querySelector(selector); + if (!(target instanceof HTMLElement)) { + throw new Error(`Expected ${selector}`); + } + target.click(); +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("favorites-drawer", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + test("mounts one fixed, closed trigger and drawer left of the native service rail", () => { + createHarness(); + + expect(document.querySelectorAll('[data-sces-favorites-tab="button"]')).toHaveLength(1); + expect(document.querySelectorAll('[data-sces-favorites-drawer="root"]')).toHaveLength(1); + expect(readTab().textContent).toContain("收藏夹"); + expect(readTab().style.position).toBe("fixed"); + expect(readTab().style.right).toBe("56px"); + expect(readTab().getAttribute("aria-expanded")).toBe("false"); + expect(readDrawer().hidden).toBe(true); + expect(readDrawer().style.position).toBe("fixed"); + expect(readDrawer().style.right).toBe("56px"); + expect(readDrawer().style.width).toBe("380px"); + expect(readDrawer().getAttribute("role")).toBe("dialog"); + }); + + test("opens, closes, and restores focus through the trigger, close control, and Escape", () => { + createHarness(); + + readTab().click(); + expect(readDrawer().hidden).toBe(false); + expect(readTab().getAttribute("aria-expanded")).toBe("true"); + expect(document.activeElement).toBe( + document.querySelector('[data-sces-favorites-drawer="close"]') + ); + + click('[data-sces-favorites-drawer="close"]'); + expect(readDrawer().hidden).toBe(true); + expect(document.activeElement).toBe(readTab()); + + readTab().click(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(readDrawer().hidden).toBe(true); + expect(document.activeElement).toBe(readTab()); + }); + + test("deduplicates creators in all view and shows their folder affiliations", () => { + const { controller } = createHarness(); + controller.render( + createState({ + creators: [ + createCreator("author-1", "Alpha"), + createCreator("author-1", "Alpha newer"), + createCreator("author-2", "Beta") + ], + memberships: [ + createMembership("author-1", firstFolder.id), + createMembership("author-1", secondFolder.id), + createMembership("author-2", secondFolder.id) + ] + }) + ); + readTab().click(); + + expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(2); + expect(readDrawer().textContent).toContain("Alpha"); + expect(readDrawer().textContent).toContain("合作、待联系"); + expect( + document.querySelector('[data-sces-favorites-remove-author-id="author-1"]') + ).toBeNull(); + }); + + test("switches to a folder view and falls back to all when that folder is deleted", () => { + const { controller } = createHarness(); + controller.render(createState()); + readTab().click(); + + click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); + expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); + expect(readDrawer().textContent).toContain("Alpha"); + expect(document.querySelector('[data-sces-favorites-remove-author-id="author-1"]')).not.toBeNull(); + expect( + document.querySelector('[data-sces-favorites-drawer="import-folder"]') + ).not.toBeNull(); + + controller.render( + createState({ + creators: [createCreator("author-2", "Beta")], + folders: [secondFolder], + memberships: [createMembership("author-2", secondFolder.id)] + }) + ); + expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); + expect(readDrawer().textContent).toContain("Beta"); + expect( + document.querySelector('[data-sces-favorites-drawer="import-folder"]') + ).toBeNull(); + }); + + test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => { + const { controller } = createHarness(); + controller.render(createState()); + readTab().click(); + + const search = document.querySelector( + '[data-sces-favorites-drawer="search"]' + ) as HTMLInputElement; + search.value = " beta "; + search.dispatchEvent(new Event("input", { bubbles: true })); + const refocusedSearch = document.querySelector( + '[data-sces-favorites-drawer="search"]' + ) as HTMLInputElement; + expect(document.activeElement).toBe(refocusedSearch); + expect(refocusedSearch.selectionStart).toBe(refocusedSearch.value.length); + expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); + expect(readDrawer().textContent).toContain("Beta"); + + const nextSearch = document.querySelector( + '[data-sces-favorites-drawer="search"]' + ) as HTMLInputElement; + nextSearch.value = "没有"; + nextSearch.dispatchEvent(new Event("input", { bubbles: true })); + expect(readDrawer().textContent).toContain("暂无收藏达人"); + }); + + test("imports only selected author ids and removes an author from the active folder", async () => { + const onImportSelected = vi.fn(async (_authorIds: string[]) => {}); + const onRemoveCreatorFromFolder = vi.fn( + async (_authorId: string, _folderId: string) => {} + ); + const { controller } = createHarness({ + handlers: { onImportSelected, onRemoveCreatorFromFolder } + }); + controller.render(createState()); + readTab().click(); + + const alpha = document.querySelector( + '[data-sces-favorites-select-author-id="author-1"]' + ) as HTMLInputElement; + const beta = document.querySelector( + '[data-sces-favorites-select-author-id="author-2"]' + ) as HTMLInputElement; + alpha.checked = true; + alpha.dispatchEvent(new Event("change", { bubbles: true })); + const nextBeta = document.querySelector( + '[data-sces-favorites-select-author-id="author-2"]' + ) as HTMLInputElement; + nextBeta.checked = true; + nextBeta.dispatchEvent(new Event("change", { bubbles: true })); + + expect(readDrawer().textContent).toContain("导入已选 2 位达人"); + click('[data-sces-favorites-drawer="import-selected"]'); + await flush(); + expect(onImportSelected).toHaveBeenCalledWith(["author-1", "author-2"]); + + click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); + click('[data-sces-favorites-remove-author-id="author-1"]'); + await flush(); + expect(onRemoveCreatorFromFolder).toHaveBeenCalledWith("author-1", firstFolder.id); + }); + + 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) => {}); + const onDeleteFolder = vi.fn(async (_folderId: string) => {}); + const { controller } = createHarness({ + handlers: { onCreateFolder, onDeleteFolder, onRenameFolder } + }); + controller.render(createState()); + readTab().click(); + + click('[data-sces-favorites-drawer="create-folder"]'); + click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`); + click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`); + await flush(); + + expect(onCreateFolder).toHaveBeenCalledTimes(1); + expect(onRenameFolder).toHaveBeenCalledWith(secondFolder.id); + expect(onDeleteFolder).toHaveBeenCalledWith(secondFolder.id); + }); + + test("confirms a deduplicated full-folder import once before importing", async () => { + const confirmImport = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + const onImportFolder = vi.fn(async (_folderId: string, _authorIds: string[]) => {}); + const { controller } = createHarness({ + confirmImport, + handlers: { onImportFolder } + }); + controller.render( + createState({ + memberships: [ + createMembership("author-1", firstFolder.id), + createMembership("author-1", firstFolder.id), + createMembership("author-2", firstFolder.id) + ] + }) + ); + readTab().click(); + + expect( + document.querySelector('[data-sces-favorites-drawer="import-folder"]') + ).toBeNull(); + click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); + click('[data-sces-favorites-drawer="import-folder"]'); + await flush(); + expect(confirmImport).toHaveBeenLastCalledWith("将当前收藏夹的 2 位达人导入秒探吗?"); + expect(onImportFolder).not.toHaveBeenCalled(); + + click('[data-sces-favorites-drawer="import-folder"]'); + await flush(); + expect(confirmImport).toHaveBeenCalledTimes(2); + expect(onImportFolder).toHaveBeenCalledWith(firstFolder.id, ["author-1", "author-2"]); + }); + + test("disables mutation and import controls while busy but keeps close usable", async () => { + const onCreateFolder = vi.fn(async () => {}); + const { controller } = createHarness({ handlers: { onCreateFolder } }); + controller.render(createState()); + readTab().click(); + controller.setBusy(true); + + expect( + (document.querySelector( + '[data-sces-favorites-drawer="create-folder"]' + ) as HTMLButtonElement).disabled + ).toBe(true); + expect( + (document.querySelector( + `[data-sces-favorites-folder-rename="${firstFolder.id}"]` + ) as HTMLButtonElement).disabled + ).toBe(true); + expect( + (document.querySelector( + '[data-sces-favorites-drawer="import-selected"]' + ) as HTMLButtonElement).disabled + ).toBe(true); + expect( + (document.querySelector( + '[data-sces-favorites-drawer="close"]' + ) as HTMLButtonElement).disabled + ).toBe(false); + + click('[data-sces-favorites-drawer="create-folder"]'); + await flush(); + expect(onCreateFolder).not.toHaveBeenCalled(); + click('[data-sces-favorites-drawer="close"]'); + expect(readDrawer().hidden).toBe(true); + }); + + test("renders errors and removes its DOM and document behavior on disposal", () => { + const { controller } = createHarness(); + controller.render(createState()); + readTab().click(); + controller.setError("保存失败"); + expect( + document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent + ).toBe("保存失败"); + + controller.dispose(); + controller.dispose(); + expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull(); + expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); +});