feat: add favorites drawer management
This commit is contained in:
@@ -0,0 +1,250 @@
|
|||||||
|
const activeDialogs = new WeakMap<
|
||||||
|
Document,
|
||||||
|
{
|
||||||
|
input: HTMLInputElement;
|
||||||
|
promise: Promise<string | null>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function promptForFavoriteFolderName(
|
||||||
|
document: Document,
|
||||||
|
options: {
|
||||||
|
initialValue?: string;
|
||||||
|
title: "新建收藏夹" | "重命名收藏夹";
|
||||||
|
}
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<string | null>((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<HTMLElement>("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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
import type {
|
||||||
|
FavoriteCreator,
|
||||||
|
FavoritesStateV1
|
||||||
|
} from "./favorites-store";
|
||||||
|
|
||||||
|
export interface FavoritesDrawerHandlers {
|
||||||
|
onCreateFolder(): Promise<void>;
|
||||||
|
onDeleteFolder(folderId: string): Promise<void>;
|
||||||
|
onImportFolder(folderId: string, authorIds: string[]): Promise<void>;
|
||||||
|
onImportSelected(authorIds: string[]): Promise<void>;
|
||||||
|
onRemoveCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
|
||||||
|
onRenameFolder(folderId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Document, FavoritesDrawerController>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<string, FavoriteCreator>();
|
||||||
|
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<string, FavoriteCreator>();
|
||||||
|
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<string>();
|
||||||
|
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>): 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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<FavoritesDrawerHandlers>;
|
||||||
|
} = {}) {
|
||||||
|
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<void> {
|
||||||
|
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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user