fix: harden favorites drawer interactions

This commit is contained in:
wxs
2026-07-17 13:22:25 +08:00
parent ba4a1052e8
commit b973e1099d
2 changed files with 423 additions and 56 deletions
+237 -54
View File
@@ -22,6 +22,20 @@ export interface FavoritesDrawerController {
const DRAWER_ID = "sces-favorites-drawer"; const DRAWER_ID = "sces-favorites-drawer";
const activeDrawers = new WeakMap<Document, FavoritesDrawerController>(); const activeDrawers = new WeakMap<Document, FavoritesDrawerController>();
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( export function createFavoritesDrawer(
document: Document, document: Document,
handlers: FavoritesDrawerHandlers, handlers: FavoritesDrawerHandlers,
@@ -58,9 +72,10 @@ export function createFavoritesDrawer(
let disposed = false; let disposed = false;
let errorMessage = ""; let errorMessage = "";
let isOpen = false; let isOpen = false;
let pending = false;
let searchQuery = ""; let searchQuery = "";
let state = emptyState(); let state = emptyState();
let view: "all" | string = "all"; let view: DrawerView = { kind: "all" };
let selectedAuthorIds = new Set<string>(); let selectedAuthorIds = new Set<string>();
const controller: FavoritesDrawerController = { const controller: FavoritesDrawerController = {
@@ -100,6 +115,9 @@ export function createFavoritesDrawer(
if (event.key !== "Escape" || !isOpen) { if (event.key !== "Escape" || !isOpen) {
return; return;
} }
if (document.querySelector("[data-sces-favorite-folder-dialog]")) {
return;
}
event.preventDefault(); event.preventDefault();
closeDrawer(); closeDrawer();
}; };
@@ -120,18 +138,20 @@ export function createFavoritesDrawer(
return; return;
} }
if (button.dataset.scesFavoritesDrawer === "view-all") { if (button.dataset.scesFavoritesDrawer === "view-all") {
view = "all"; view = { kind: "all" };
renderDrawer(); renderDrawer();
return; return;
} }
const folderId = button.dataset.scesFavoritesFolderId; if (button.hasAttribute("data-sces-favorites-folder-id")) {
if (folderId) { view = {
view = folderId; folderId: button.getAttribute("data-sces-favorites-folder-id") ?? "",
kind: "folder"
};
renderDrawer(); renderDrawer();
return; return;
} }
if (busy) { if (isInteractionLocked()) {
return; return;
} }
@@ -140,21 +160,30 @@ export function createFavoritesDrawer(
return; return;
} }
const renameFolderId = button.dataset.scesFavoritesFolderRename; if (button.hasAttribute("data-sces-favorites-folder-rename")) {
if (renameFolderId) { const renameFolderId =
button.getAttribute("data-sces-favorites-folder-rename") ?? "";
invokeHandler(() => handlers.onRenameFolder(renameFolderId)); invokeHandler(() => handlers.onRenameFolder(renameFolderId));
return; return;
} }
const deleteFolderId = button.dataset.scesFavoritesFolderDelete; if (button.hasAttribute("data-sces-favorites-folder-delete")) {
if (deleteFolderId) { const deleteFolderId =
button.getAttribute("data-sces-favorites-folder-delete") ?? "";
invokeHandler(() => handlers.onDeleteFolder(deleteFolderId)); invokeHandler(() => handlers.onDeleteFolder(deleteFolderId));
return; return;
} }
const removeAuthorId = button.dataset.scesFavoritesRemoveAuthorId; if (
if (removeAuthorId && view !== "all") { button.hasAttribute("data-sces-favorites-remove-author-id") &&
invokeHandler(() => handlers.onRemoveCreatorFromFolder(removeAuthorId, view)); view.kind === "folder"
) {
const removeAuthorId =
button.getAttribute("data-sces-favorites-remove-author-id") ?? "";
const folderId = view.folderId;
invokeHandler(() =>
handlers.onRemoveCreatorFromFolder(removeAuthorId, folderId)
);
return; return;
} }
@@ -166,11 +195,15 @@ export function createFavoritesDrawer(
return; 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 authorIds = creatorsInCurrentView().map((creator) => creator.authorId);
const folderId = view.folderId;
const message = `将当前收藏夹的 ${authorIds.length} 位达人导入秒探吗?`; const message = `将当前收藏夹的 ${authorIds.length} 位达人导入秒探吗?`;
if (confirmImport(message)) { if (confirmImport(message)) {
invokeHandler(() => handlers.onImportFolder(view, authorIds)); invokeHandler(() => handlers.onImportFolder(folderId, authorIds));
} }
} }
}; };
@@ -182,25 +215,15 @@ export function createFavoritesDrawer(
target.dataset.scesFavoritesDrawer === "search" target.dataset.scesFavoritesDrawer === "search"
) { ) {
searchQuery = target.value; searchQuery = target.value;
const selectionStart = target.selectionStart; target.focus();
const selectionEnd = target.selectionEnd;
renderDrawer(); 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 onDrawerChange = (event: Event) => {
const target = event.target; const target = event.target;
if ( if (
busy || isInteractionLocked() ||
!(target instanceof HTMLInputElement) || !(target instanceof HTMLInputElement) ||
target.type !== "checkbox" target.type !== "checkbox"
) { ) {
@@ -222,7 +245,7 @@ export function createFavoritesDrawer(
drawer.addEventListener("click", onDrawerClick); drawer.addEventListener("click", onDrawerClick);
drawer.addEventListener("input", onDrawerInput); drawer.addEventListener("input", onDrawerInput);
drawer.addEventListener("change", onDrawerChange); drawer.addEventListener("change", onDrawerChange);
document.addEventListener("keydown", onDocumentKeydown); document.addEventListener("keydown", onDocumentKeydown, true);
renderDrawer(); renderDrawer();
activeDrawers.set(document, controller); activeDrawers.set(document, controller);
@@ -261,8 +284,12 @@ export function createFavoritesDrawer(
} }
function renderDrawer(): void { function renderDrawer(): void {
if (view !== "all" && !state.folders.some((folder) => folder.id === view)) { const focusDescriptor = captureFocusedControl();
view = "all"; if (view.kind === "folder") {
const folderId = view.folderId;
if (!state.folders.some((folder) => folder.id === folderId)) {
view = { kind: "all" };
}
} }
const currentCreators = creatorsInCurrentView(); const currentCreators = creatorsInCurrentView();
@@ -291,6 +318,7 @@ export function createFavoritesDrawer(
fragment.append(createCreatorList(visibleCreators)); fragment.append(createCreatorList(visibleCreators));
fragment.append(createFooter(selectedCount)); fragment.append(createFooter(selectedCount));
drawer.replaceChildren(fragment); drawer.replaceChildren(fragment);
restoreFocusedControl(focusDescriptor);
} }
function createHeader(): HTMLElement { function createHeader(): HTMLElement {
@@ -306,12 +334,13 @@ export function createFavoritesDrawer(
const createFolder = createButton(document, "新建收藏夹"); const createFolder = createButton(document, "新建收藏夹");
createFolder.dataset.scesFavoritesDrawer = "create-folder"; createFolder.dataset.scesFavoritesDrawer = "create-folder";
createFolder.disabled = busy; createFolder.disabled = isInteractionLocked();
applySecondaryButtonStyles(createFolder); applySecondaryButtonStyles(createFolder);
const close = createButton(document, "关闭收藏夹"); const close = createButton(document, "x");
close.dataset.scesFavoritesDrawer = "close"; close.dataset.scesFavoritesDrawer = "close";
close.setAttribute("aria-label", "关闭收藏夹"); close.setAttribute("aria-label", "关闭收藏夹");
close.title = "关闭收藏夹";
applyIconButtonStyles(close); applyIconButtonStyles(close);
actions.append(createFolder, close); actions.append(createFolder, close);
@@ -326,8 +355,8 @@ export function createFavoritesDrawer(
const allButton = createButton(document, "全部收藏"); const allButton = createButton(document, "全部收藏");
allButton.dataset.scesFavoritesDrawer = "view-all"; allButton.dataset.scesFavoritesDrawer = "view-all";
allButton.setAttribute("aria-current", String(view === "all")); allButton.setAttribute("aria-current", String(view.kind === "all"));
applyViewButtonStyles(allButton, view === "all"); applyViewButtonStyles(allButton, view.kind === "all");
navigation.append(allButton); navigation.append(allButton);
state.folders.forEach((folder) => { state.folders.forEach((folder) => {
@@ -336,19 +365,21 @@ export function createFavoritesDrawer(
const viewButton = createButton(document, folder.name); const viewButton = createButton(document, folder.name);
viewButton.dataset.scesFavoritesFolderId = folder.id; viewButton.dataset.scesFavoritesFolderId = folder.id;
viewButton.setAttribute("aria-current", String(view === folder.id)); const isCurrentFolder =
applyViewButtonStyles(viewButton, view === folder.id); view.kind === "folder" && view.folderId === folder.id;
viewButton.setAttribute("aria-current", String(isCurrentFolder));
applyViewButtonStyles(viewButton, isCurrentFolder);
const rename = createButton(document, "重命名"); const rename = createButton(document, "重命名");
rename.dataset.scesFavoritesFolderRename = folder.id; rename.dataset.scesFavoritesFolderRename = folder.id;
rename.setAttribute("aria-label", `重命名收藏夹 ${folder.name}`); rename.setAttribute("aria-label", `重命名收藏夹 ${folder.name}`);
rename.disabled = busy; rename.disabled = isInteractionLocked();
applyTextActionStyles(rename); applyTextActionStyles(rename);
const remove = createButton(document, "删除"); const remove = createButton(document, "删除");
remove.dataset.scesFavoritesFolderDelete = folder.id; remove.dataset.scesFavoritesFolderDelete = folder.id;
remove.setAttribute("aria-label", `删除收藏夹 ${folder.name}`); remove.setAttribute("aria-label", `删除收藏夹 ${folder.name}`);
remove.disabled = busy; remove.disabled = isInteractionLocked();
applyDangerActionStyles(remove); applyDangerActionStyles(remove);
row.append(viewButton, rename, remove); row.append(viewButton, rename, remove);
@@ -393,7 +424,7 @@ export function createFavoritesDrawer(
const checkbox = document.createElement("input"); const checkbox = document.createElement("input");
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = selectedAuthorIds.has(creator.authorId); checkbox.checked = selectedAuthorIds.has(creator.authorId);
checkbox.disabled = busy; checkbox.disabled = isInteractionLocked();
checkbox.dataset.scesFavoritesSelectAuthorId = creator.authorId; checkbox.dataset.scesFavoritesSelectAuthorId = creator.authorId;
checkbox.setAttribute("aria-label", `选择达人 ${creator.authorName}`); checkbox.setAttribute("aria-label", `选择达人 ${creator.authorName}`);
selectLabel.append(checkbox); selectLabel.append(checkbox);
@@ -406,7 +437,7 @@ export function createFavoritesDrawer(
applyCreatorNameStyles(name); applyCreatorNameStyles(name);
details.append(name); details.append(name);
if (view === "all") { if (view.kind === "all") {
const affiliation = document.createElement("p"); const affiliation = document.createElement("p");
affiliation.textContent = `所属:${folderNamesForCreator(creator.authorId).join("、") || "未分类"}`; affiliation.textContent = `所属:${folderNamesForCreator(creator.authorId).join("、") || "未分类"}`;
applyAffiliationStyles(affiliation); applyAffiliationStyles(affiliation);
@@ -414,11 +445,11 @@ export function createFavoritesDrawer(
} }
row.append(selectLabel, details); row.append(selectLabel, details);
if (view !== "all") { if (view.kind === "folder") {
const remove = createButton(document, "移出收藏夹"); const remove = createButton(document, "移出收藏夹");
remove.dataset.scesFavoritesRemoveAuthorId = creator.authorId; remove.dataset.scesFavoritesRemoveAuthorId = creator.authorId;
remove.setAttribute("aria-label", `${creator.authorName} 移出当前收藏夹`); remove.setAttribute("aria-label", `${creator.authorName} 移出当前收藏夹`);
remove.disabled = busy; remove.disabled = isInteractionLocked();
applyDangerActionStyles(remove); applyDangerActionStyles(remove);
row.append(remove); row.append(remove);
} }
@@ -429,6 +460,7 @@ export function createFavoritesDrawer(
function createFooter(selectedCount: number): HTMLElement { function createFooter(selectedCount: number): HTMLElement {
const footer = document.createElement("footer"); const footer = document.createElement("footer");
footer.dataset.scesFavoritesDrawer = "footer";
applyFooterStyles(footer); applyFooterStyles(footer);
const selected = document.createElement("span"); const selected = document.createElement("span");
@@ -437,18 +469,20 @@ export function createFavoritesDrawer(
const actions = document.createElement("div"); const actions = document.createElement("div");
applyFooterActionsStyles(actions); applyFooterActionsStyles(actions);
if (view !== "all") { if (view.kind === "folder") {
const importFolder = createButton(document, "导入当前收藏夹全部达人"); const importFolder = createButton(document, "导入当前收藏夹全部达人");
importFolder.dataset.scesFavoritesDrawer = "import-folder"; importFolder.dataset.scesFavoritesDrawer = "import-folder";
importFolder.disabled = busy; importFolder.disabled = isInteractionLocked();
applySecondaryButtonStyles(importFolder); applySecondaryButtonStyles(importFolder);
applyFooterActionStyles(importFolder);
actions.append(importFolder); actions.append(importFolder);
} }
const importSelected = createButton(document, `导入已选 ${selectedCount} 位达人`); const importSelected = createButton(document, `导入已选 ${selectedCount} 位达人`);
importSelected.dataset.scesFavoritesDrawer = "import-selected"; importSelected.dataset.scesFavoritesDrawer = "import-selected";
importSelected.disabled = busy || selectedCount === 0; importSelected.disabled = isInteractionLocked() || selectedCount === 0;
applyPrimaryButtonStyles(importSelected); applyPrimaryButtonStyles(importSelected);
applyFooterActionStyles(importSelected);
actions.append(importSelected); actions.append(importSelected);
footer.append(selected, actions); footer.append(selected, actions);
@@ -457,7 +491,7 @@ export function createFavoritesDrawer(
function creatorsInCurrentView(): FavoriteCreator[] { function creatorsInCurrentView(): FavoriteCreator[] {
const uniqueCreators = new Map<string, FavoriteCreator>(); const uniqueCreators = new Map<string, FavoriteCreator>();
if (view === "all") { if (view.kind === "all") {
state.creators.forEach((creator) => { state.creators.forEach((creator) => {
if (!uniqueCreators.has(creator.authorId)) { if (!uniqueCreators.has(creator.authorId)) {
uniqueCreators.set(creator.authorId, creator); uniqueCreators.set(creator.authorId, creator);
@@ -472,8 +506,12 @@ export function createFavoritesDrawer(
creatorsById.set(creator.authorId, creator); creatorsById.set(creator.authorId, creator);
} }
}); });
const folderId = view.folderId;
state.memberships.forEach((membership) => { state.memberships.forEach((membership) => {
if (membership.folderId !== view || uniqueCreators.has(membership.authorId)) { if (
membership.folderId !== folderId ||
uniqueCreators.has(membership.authorId)
) {
return; return;
} }
const creator = creatorsById.get(membership.authorId); const creator = creatorsById.get(membership.authorId);
@@ -503,14 +541,138 @@ export function createFavoritesDrawer(
return Array.from(names); return Array.from(names);
} }
function invokeHandler(handler: () => Promise<void>): void { function captureFocusedControl(): DrawerFocusDescriptor | null {
if (busy || disposed) { 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; return;
} }
void handler().catch((error: unknown) => {
errorMessage = error instanceof Error ? error.message : "操作失败"; 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<HTMLElement>(`[${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>): void {
if (isInteractionLocked() || disposed) {
return;
}
pending = true;
renderDrawer(); 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 { function dispose(): void {
@@ -522,7 +684,7 @@ export function createFavoritesDrawer(
drawer.removeEventListener("click", onDrawerClick); drawer.removeEventListener("click", onDrawerClick);
drawer.removeEventListener("input", onDrawerInput); drawer.removeEventListener("input", onDrawerInput);
drawer.removeEventListener("change", onDrawerChange); drawer.removeEventListener("change", onDrawerChange);
document.removeEventListener("keydown", onDocumentKeydown); document.removeEventListener("keydown", onDocumentKeydown, true);
tab.remove(); tab.remove();
drawer.remove(); drawer.remove();
if (activeDrawers.get(document) === controller) { if (activeDrawers.get(document) === controller) {
@@ -706,13 +868,19 @@ function applyFooterStyles(footer: HTMLElement): void {
footer.style.display = "flex"; footer.style.display = "flex";
footer.style.alignItems = "center"; footer.style.alignItems = "center";
footer.style.justifyContent = "space-between"; footer.style.justifyContent = "space-between";
footer.style.flexWrap = "wrap";
footer.style.gap = "12px"; footer.style.gap = "12px";
footer.style.padding = "12px 16px"; footer.style.padding = "12px 16px";
footer.style.width = "100%";
footer.style.minWidth = "0";
footer.style.boxSizing = "border-box";
footer.style.borderTop = "1px solid #e2e8f0"; footer.style.borderTop = "1px solid #e2e8f0";
} }
function applySelectedCountStyles(selected: HTMLElement): void { 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.color = "#475569";
selected.style.fontSize = "12px"; selected.style.fontSize = "12px";
} }
@@ -721,8 +889,19 @@ function applyFooterActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex"; actions.style.display = "flex";
actions.style.alignItems = "center"; actions.style.alignItems = "center";
actions.style.justifyContent = "flex-end"; actions.style.justifyContent = "flex-end";
actions.style.flexWrap = "wrap";
actions.style.gap = "8px"; actions.style.gap = "8px";
actions.style.minWidth = "0"; 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 { function applyErrorStyles(error: HTMLElement): void {
@@ -743,6 +922,8 @@ function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.color = "#334155"; button.style.color = "#334155";
button.style.cursor = "pointer"; button.style.cursor = "pointer";
button.style.fontSize = "12px"; button.style.fontSize = "12px";
button.style.boxSizing = "border-box";
button.style.maxWidth = "100%";
} }
function applyPrimaryButtonStyles(button: HTMLButtonElement): void { function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
@@ -754,6 +935,8 @@ function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.color = "#ffffff"; button.style.color = "#ffffff";
button.style.cursor = "pointer"; button.style.cursor = "pointer";
button.style.fontSize = "12px"; button.style.fontSize = "12px";
button.style.boxSizing = "border-box";
button.style.maxWidth = "100%";
} }
function applyIconButtonStyles(button: HTMLButtonElement): void { function applyIconButtonStyles(button: HTMLButtonElement): void {
+185 -1
View File
@@ -6,6 +6,7 @@ import {
createFavoritesDrawer, createFavoritesDrawer,
type FavoritesDrawerHandlers type FavoritesDrawerHandlers
} from "../src/content/market/favorites-drawer"; } from "../src/content/market/favorites-drawer";
import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog";
import type { import type {
FavoriteCreator, FavoriteCreator,
FavoriteFolder, FavoriteFolder,
@@ -107,6 +108,14 @@ async function flush(): Promise<void> {
await Promise.resolve(); await Promise.resolve();
} }
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
describe("favorites-drawer", () => { describe("favorites-drawer", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
@@ -157,6 +166,42 @@ describe("favorites-drawer", () => {
expect(document.activeElement).toBe(readTab()); 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", () => { test("deduplicates creators in all view and shows their folder affiliations", () => {
const { controller } = createHarness(); const { controller } = createHarness();
controller.render( controller.render(
@@ -210,6 +255,31 @@ describe("favorites-drawer", () => {
).toBeNull(); ).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", () => { test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => {
const { controller } = createHarness(); const { controller } = createHarness();
controller.render(createState()); controller.render(createState());
@@ -306,6 +376,114 @@ describe("favorites-drawer", () => {
expect(onImportSelected).toHaveBeenCalledWith(["111"]); 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<string | null> | 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 () => { test("calls create, rename, and delete handlers with the matching folder id", async () => {
const onCreateFolder = vi.fn(async () => {}); const onCreateFolder = vi.fn(async () => {});
const onRenameFolder = vi.fn(async (_folderId: string) => {}); const onRenameFolder = vi.fn(async (_folderId: string) => {});
@@ -317,7 +495,9 @@ describe("favorites-drawer", () => {
readTab().click(); readTab().click();
click('[data-sces-favorites-drawer="create-folder"]'); click('[data-sces-favorites-drawer="create-folder"]');
await flush();
click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`); click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`);
await flush();
click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`); click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`);
await flush(); await flush();
@@ -412,7 +592,11 @@ describe("favorites-drawer", () => {
controller.dispose(); controller.dispose();
expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull(); expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull();
expect(document.querySelector('[data-sces-favorites-drawer="root"]')).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"); const unrelatedButton = document.createElement("button");
unrelatedButton.type = "button"; unrelatedButton.type = "button";