fix: harden favorites drawer interactions
This commit is contained in:
@@ -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;
|
||||||
renderDrawer();
|
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();
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user