fix: harden favorite row picker

This commit is contained in:
wxs
2026-07-17 12:40:01 +08:00
parent 4c1ccb92db
commit e08fc99def
2 changed files with 347 additions and 48 deletions
+178 -45
View File
@@ -2,6 +2,10 @@ import type { FavoriteCreatorInput, FavoriteFolder } from "./favorites-store";
const ACTION_SELECTOR = 'button[data-sces-favorite-row-action="button"]'; const ACTION_SELECTOR = 'button[data-sces-favorite-row-action="button"]';
const PICKER_ROOT_SELECTOR = '[data-sces-favorite-row-picker="root"]'; const PICKER_ROOT_SELECTOR = '[data-sces-favorite-row-picker="root"]';
const PICKER_ROOT_ID = "sces-favorite-row-picker";
const PICKER_VIEWPORT_PADDING = 8;
const PICKER_FALLBACK_HEIGHT = 120;
const PICKER_FALLBACK_WIDTH = 240;
export interface FavoriteRowPickerRow extends FavoriteCreatorInput { export interface FavoriteRowPickerRow extends FavoriteCreatorInput {
actionCell?: HTMLElement; actionCell?: HTMLElement;
@@ -28,6 +32,7 @@ export function syncFavoriteRowPickers(options: {
const button = ensureFavoriteActionButton(row.actionCell); const button = ensureFavoriteActionButton(row.actionCell);
const authorId = row.authorId.trim(); const authorId = row.authorId.trim();
const pendingOperation = state.pendingOperations.get(button);
const context: FavoritePickerContext = { const context: FavoritePickerContext = {
button, button,
creator: { creator: {
@@ -38,15 +43,22 @@ export function syncFavoriteRowPickers(options: {
folders: [...options.folders], folders: [...options.folders],
onCreateFolder: options.onCreateFolder, onCreateFolder: options.onCreateFolder,
onSetCreatorFolderIds: options.onSetCreatorFolderIds, onSetCreatorFolderIds: options.onSetCreatorFolderIds,
pending: false,
selectedFolderIds: new Set( selectedFolderIds: new Set(
authorId ? options.folderIdsByAuthorId.get(authorId) ?? [] : [] pendingOperation
? pendingOperation.confirmedFolderIds
: authorId
? options.folderIdsByAuthorId.get(authorId) ?? []
: []
) )
}; };
currentButtons.add(button); currentButtons.add(button);
state.contextByButton.set(button, context); state.contextByButton.set(button, context);
syncFavoriteActionButton(context); syncFavoriteActionButton(
context,
Boolean(pendingOperation),
state.openPicker?.button === button
);
button.onclick = () => { button.onclick = () => {
openFavoritePicker(options.document, state, button); openFavoritePicker(options.document, state, button);
}; };
@@ -57,6 +69,7 @@ export function syncFavoriteRowPickers(options: {
continue; continue;
} }
state.contextByButton.delete(button); state.contextByButton.delete(button);
state.pendingOperations.delete(button);
button.onclick = null; button.onclick = null;
} }
@@ -83,10 +96,13 @@ type FavoritePickerContext = {
creator: FavoriteCreatorInput, creator: FavoriteCreatorInput,
folderIds: string[] folderIds: string[]
) => Promise<void>; ) => Promise<void>;
pending: boolean;
selectedFolderIds: Set<string>; selectedFolderIds: Set<string>;
}; };
type FavoritePickerOperation = {
confirmedFolderIds: Set<string>;
};
type OpenFavoritePicker = { type OpenFavoritePicker = {
button: HTMLButtonElement; button: HTMLButtonElement;
root: HTMLElement; root: HTMLElement;
@@ -97,6 +113,7 @@ type FavoritePickerDocumentState = {
onDocumentKeydown: (event: KeyboardEvent) => void; onDocumentKeydown: (event: KeyboardEvent) => void;
onDocumentPointerDown: (event: PointerEvent) => void; onDocumentPointerDown: (event: PointerEvent) => void;
openPicker: OpenFavoritePicker | null; openPicker: OpenFavoritePicker | null;
pendingOperations: Map<HTMLButtonElement, FavoritePickerOperation>;
}; };
const pickerDocumentStates = new WeakMap<Document, FavoritePickerDocumentState>(); const pickerDocumentStates = new WeakMap<Document, FavoritePickerDocumentState>();
@@ -111,7 +128,8 @@ function getPickerDocumentState(document: Document): FavoritePickerDocumentState
contextByButton: new Map<HTMLButtonElement, FavoritePickerContext>(), contextByButton: new Map<HTMLButtonElement, FavoritePickerContext>(),
onDocumentKeydown: () => {}, onDocumentKeydown: () => {},
onDocumentPointerDown: () => {}, onDocumentPointerDown: () => {},
openPicker: null openPicker: null,
pendingOperations: new Map<HTMLButtonElement, FavoritePickerOperation>()
}; };
state.onDocumentKeydown = (event) => { state.onDocumentKeydown = (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
@@ -156,7 +174,11 @@ function ensureFavoriteActionButton(actionCell: HTMLElement): HTMLButtonElement
return button; return button;
} }
function syncFavoriteActionButton(context: FavoritePickerContext): void { function syncFavoriteActionButton(
context: FavoritePickerContext,
isPending = false,
isExpanded = false
): void {
const authorId = context.creator.authorId.trim(); const authorId = context.creator.authorId.trim();
const count = context.selectedFolderIds.size; const count = context.selectedFolderIds.size;
const isSaved = count > 0; const isSaved = count > 0;
@@ -166,6 +188,9 @@ function syncFavoriteActionButton(context: FavoritePickerContext): void {
? "bookmark-filled" ? "bookmark-filled"
: "bookmark-outline"; : "bookmark-outline";
context.button.dataset.scesFavoriteState = isSaved ? "saved" : "empty"; context.button.dataset.scesFavoriteState = isSaved ? "saved" : "empty";
context.button.setAttribute("aria-controls", PICKER_ROOT_ID);
context.button.setAttribute("aria-expanded", String(isExpanded));
context.button.setAttribute("aria-haspopup", "dialog");
if (!authorId) { if (!authorId) {
context.button.disabled = true; context.button.disabled = true;
@@ -175,7 +200,7 @@ function syncFavoriteActionButton(context: FavoritePickerContext): void {
return; return;
} }
context.button.disabled = false; context.button.disabled = isPending;
context.button.title = isSaved ? `已收藏至 ${count} 个收藏夹` : "加入收藏夹"; context.button.title = isSaved ? `已收藏至 ${count} 个收藏夹` : "加入收藏夹";
context.button.setAttribute("aria-label", context.button.title); context.button.setAttribute("aria-label", context.button.title);
context.button.textContent = isSaved ? "已收藏" : "收藏"; context.button.textContent = isSaved ? "已收藏" : "收藏";
@@ -197,20 +222,22 @@ function openFavoritePicker(
const root = document.createElement("div"); const root = document.createElement("div");
root.dataset.scesFavoriteRowPicker = "root"; root.dataset.scesFavoriteRowPicker = "root";
root.id = PICKER_ROOT_ID;
root.setAttribute("role", "dialog"); root.setAttribute("role", "dialog");
root.setAttribute("aria-label", "选择收藏夹"); root.setAttribute("aria-label", "选择收藏夹");
root.style.position = "fixed"; root.style.position = "fixed";
root.style.zIndex = "2147483647"; root.style.zIndex = "2147483647";
const rect = button.getBoundingClientRect();
root.style.left = `${Math.round(rect.left)}px`;
root.style.top = `${Math.round(rect.bottom + 4)}px`;
(document.body ?? document.documentElement).appendChild(root); (document.body ?? document.documentElement).appendChild(root);
state.openPicker = { button, root }; state.openPicker = { button, root };
button.setAttribute("aria-expanded", "true");
document.addEventListener("pointerdown", state.onDocumentPointerDown); document.addEventListener("pointerdown", state.onDocumentPointerDown);
document.addEventListener("keydown", state.onDocumentKeydown); document.addEventListener("keydown", state.onDocumentKeydown);
} }
renderFavoritePicker(document, state, button); renderFavoritePicker(document, state, button);
if (state.openPicker?.button === button) {
focusFavoritePickerControl(state.openPicker.root);
}
} }
function closeFavoritePicker( function closeFavoritePicker(
@@ -226,6 +253,10 @@ function closeFavoritePicker(
openPicker.root.remove(); openPicker.root.remove();
document.removeEventListener("pointerdown", state.onDocumentPointerDown); document.removeEventListener("pointerdown", state.onDocumentPointerDown);
document.removeEventListener("keydown", state.onDocumentKeydown); document.removeEventListener("keydown", state.onDocumentKeydown);
openPicker.button.setAttribute("aria-expanded", "false");
if (openPicker.button.isConnected) {
openPicker.button.focus();
}
} }
function removeFavoritePickerRoots(document: Document): void { function removeFavoritePickerRoots(document: Document): void {
@@ -244,6 +275,7 @@ function renderFavoritePicker(
} }
const root = openPicker.root; const root = openPicker.root;
const isPending = state.pendingOperations.has(button);
const folderList = document.createElement("div"); const folderList = document.createElement("div");
context.folders.forEach((folder) => { context.folders.forEach((folder) => {
const label = document.createElement("label"); const label = document.createElement("label");
@@ -251,11 +283,10 @@ function renderFavoritePicker(
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.dataset.scesFavoriteFolderId = folder.id; checkbox.dataset.scesFavoriteFolderId = folder.id;
checkbox.checked = context.selectedFolderIds.has(folder.id); checkbox.checked = context.selectedFolderIds.has(folder.id);
checkbox.disabled = context.pending; checkbox.disabled = isPending;
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
const nextFolderIds = readCheckedFolderIds(root, context); const nextFolderIds = readCheckedFolderIds(root, context);
renderFavoritePicker(document, state, button); beginFavoriteFolderSave(document, state, context, nextFolderIds);
void saveFavoriteFolderIds(document, state, context, nextFolderIds);
}); });
label.append(checkbox, document.createTextNode(folder.name)); label.append(checkbox, document.createTextNode(folder.name));
@@ -266,12 +297,13 @@ function renderFavoritePicker(
createButton.type = "button"; createButton.type = "button";
createButton.dataset.scesFavoriteRowPicker = "create-folder"; createButton.dataset.scesFavoriteRowPicker = "create-folder";
createButton.textContent = "新建收藏夹"; createButton.textContent = "新建收藏夹";
createButton.disabled = context.pending; createButton.disabled = isPending;
createButton.addEventListener("click", () => { createButton.addEventListener("click", () => {
void createAndSaveFavoriteFolder(document, state, context); void createAndSaveFavoriteFolder(document, state, context);
}); });
root.replaceChildren(folderList, createButton); root.replaceChildren(folderList, createButton);
positionFavoritePicker(document, root, button);
} }
function readCheckedFolderIds( function readCheckedFolderIds(
@@ -293,32 +325,39 @@ function readCheckedFolderIds(
return [...selectedFolderIds]; return [...selectedFolderIds];
} }
async function saveFavoriteFolderIds( function beginFavoriteFolderSave(
document: Document, document: Document,
state: FavoritePickerDocumentState, state: FavoritePickerDocumentState,
context: FavoritePickerContext, context: FavoritePickerContext,
nextFolderIds: string[], nextFolderIds: string[]
createdFolder?: FavoriteFolder ): void {
): Promise<void> { const operation = beginFavoritePickerOperation(state, context);
context.pending = true; syncFavoriteActionButton(context, true, state.openPicker?.button === context.button);
renderFavoritePicker(document, state, context.button); renderFavoritePicker(document, state, context.button);
void completeFavoriteFolderSave(
document,
state,
context,
operation,
nextFolderIds
);
}
async function completeFavoriteFolderSave(
document: Document,
state: FavoritePickerDocumentState,
context: FavoritePickerContext,
operation: FavoritePickerOperation,
nextFolderIds: string[]
): Promise<void> {
try { try {
await context.onSetCreatorFolderIds(context.creator, nextFolderIds); await context.onSetCreatorFolderIds(context.creator, nextFolderIds);
if (state.contextByButton.get(context.button) !== context) { finishFavoritePickerOperation(document, state, context.button, operation, {
return; folderIds: nextFolderIds
} });
context.selectedFolderIds = new Set(nextFolderIds);
if (createdFolder && !context.folders.some((folder) => folder.id === createdFolder.id)) {
context.folders = [...context.folders, createdFolder];
}
syncFavoriteActionButton(context);
} catch { } catch {
// The picker keeps the last confirmed selection when persistence fails. finishFavoritePickerOperation(document, state, context.button, operation);
} finally {
context.pending = false;
renderFavoritePicker(document, state, context.button);
} }
} }
@@ -327,33 +366,127 @@ async function createAndSaveFavoriteFolder(
state: FavoritePickerDocumentState, state: FavoritePickerDocumentState,
context: FavoritePickerContext context: FavoritePickerContext
): Promise<void> { ): Promise<void> {
if (context.pending) { if (state.pendingOperations.has(context.button)) {
return; return;
} }
context.pending = true; const operation = beginFavoritePickerOperation(state, context);
syncFavoriteActionButton(context, true, state.openPicker?.button === context.button);
renderFavoritePicker(document, state, context.button); renderFavoritePicker(document, state, context.button);
try { try {
const folder = await context.onCreateFolder(); const folder = await context.onCreateFolder();
if (!folder) { if (!folder) {
finishFavoritePickerOperation(document, state, context.button, operation);
return; return;
} }
const nextFolderIds = [...context.selectedFolderIds, folder.id]; const nextFolderIds = [...new Set([...context.selectedFolderIds, folder.id])];
await context.onSetCreatorFolderIds(context.creator, nextFolderIds); await context.onSetCreatorFolderIds(context.creator, nextFolderIds);
if (state.contextByButton.get(context.button) !== context) { finishFavoritePickerOperation(document, state, context.button, operation, {
createdFolder: folder,
folderIds: nextFolderIds
});
} catch {
finishFavoritePickerOperation(document, state, context.button, operation);
}
}
function beginFavoritePickerOperation(
state: FavoritePickerDocumentState,
context: FavoritePickerContext
): FavoritePickerOperation {
const operation: FavoritePickerOperation = {
confirmedFolderIds: new Set(context.selectedFolderIds)
};
state.pendingOperations.set(context.button, operation);
return operation;
}
function finishFavoritePickerOperation(
document: Document,
state: FavoritePickerDocumentState,
button: HTMLButtonElement,
operation: FavoritePickerOperation,
result?: {
createdFolder?: FavoriteFolder;
folderIds: string[];
}
): void {
if (state.pendingOperations.get(button) !== operation) {
return; return;
} }
context.selectedFolderIds = new Set(nextFolderIds); state.pendingOperations.delete(button);
if (!context.folders.some((existingFolder) => existingFolder.id === folder.id)) { const currentContext = state.contextByButton.get(button);
context.folders = [...context.folders, folder]; if (!currentContext || !button.isConnected) {
return;
} }
syncFavoriteActionButton(context);
} catch { if (result) {
// The picker keeps the last confirmed selection when creating or saving fails. currentContext.selectedFolderIds = new Set(result.folderIds);
} finally { if (
context.pending = false; result.createdFolder &&
renderFavoritePicker(document, state, context.button); !currentContext.folders.some((folder) => folder.id === result.createdFolder?.id)
) {
currentContext.folders = [...currentContext.folders, result.createdFolder];
} }
} }
syncFavoriteActionButton(
currentContext,
false,
state.openPicker?.button === button
);
renderFavoritePicker(document, state, button);
}
function focusFavoritePickerControl(root: HTMLElement): void {
const firstCheckbox = Array.from(
root.querySelectorAll<HTMLInputElement>("input[data-sces-favorite-folder-id]")
).find((checkbox) => !checkbox.disabled);
const createButton = root.querySelector<HTMLButtonElement>(
'[data-sces-favorite-row-picker="create-folder"]'
);
(firstCheckbox ?? (createButton && !createButton.disabled ? createButton : null))?.focus();
}
function positionFavoritePicker(
document: Document,
root: HTMLElement,
button: HTMLButtonElement
): void {
const triggerRect = button.getBoundingClientRect();
const pickerRect = root.getBoundingClientRect();
const pickerWidth = pickerRect.width || root.offsetWidth || PICKER_FALLBACK_WIDTH;
const pickerHeight = pickerRect.height || root.offsetHeight || PICKER_FALLBACK_HEIGHT;
const viewportWidth =
document.documentElement.clientWidth ||
document.defaultView?.innerWidth ||
pickerWidth + PICKER_VIEWPORT_PADDING * 2;
const viewportHeight =
document.documentElement.clientHeight ||
document.defaultView?.innerHeight ||
pickerHeight + PICKER_VIEWPORT_PADDING * 2;
const maxLeft = Math.max(
PICKER_VIEWPORT_PADDING,
viewportWidth - pickerWidth - PICKER_VIEWPORT_PADDING
);
const maxTop = Math.max(
PICKER_VIEWPORT_PADDING,
viewportHeight - pickerHeight - PICKER_VIEWPORT_PADDING
);
const belowTop = triggerRect.bottom + PICKER_VIEWPORT_PADDING;
const preferredTop =
belowTop + pickerHeight > viewportHeight - PICKER_VIEWPORT_PADDING
? triggerRect.top - PICKER_VIEWPORT_PADDING - pickerHeight
: belowTop;
const left = clamp(triggerRect.left, PICKER_VIEWPORT_PADDING, maxLeft);
const top = clamp(preferredTop, PICKER_VIEWPORT_PADDING, maxTop);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
+166
View File
@@ -50,6 +50,44 @@ function readPopover(): HTMLElement | null {
return document.querySelector('[data-sces-favorite-row-picker="root"]'); return document.querySelector('[data-sces-favorite-row-picker="root"]');
} }
function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
} {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createRect(options: {
bottom?: number;
height?: number;
left?: number;
top?: number;
width?: number;
}): DOMRect {
const bottom = options.bottom ?? (options.top ?? 0) + (options.height ?? 0);
const height = options.height ?? bottom - (options.top ?? 0);
const left = options.left ?? 0;
const top = options.top ?? bottom - height;
const width = options.width ?? 0;
return {
bottom,
height,
left,
right: left + width,
toJSON() {
return {};
},
top,
width,
x: left,
y: top
} as DOMRect;
}
function sync(options: { function sync(options: {
actionCell?: HTMLElement; actionCell?: HTMLElement;
authorId?: string; authorId?: string;
@@ -208,6 +246,50 @@ describe("favorite-row-picker", () => {
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("2"); expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("2");
}); });
test("retains a confirmed save through a resync while it is pending", async () => {
const actionCell = createActionCell();
const deferredSave = createDeferred();
const onSetCreatorFolderIds = vi.fn(() => deferredSave.promise);
sync({ actionCell, onSetCreatorFolderIds });
readFavoriteButton(actionCell).click();
const firstCheckbox = document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement;
firstCheckbox.checked = true;
firstCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
sync({ actionCell, onSetCreatorFolderIds });
expect(readFavoriteButton(actionCell).disabled).toBe(true);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).disabled
).toBe(true);
deferredSave.resolve();
await flushAsyncEvents();
expect(readFavoriteButton(actionCell).disabled).toBe(false);
expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1");
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).checked
).toBe(true);
expect(
(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
) as HTMLInputElement
).disabled
).toBe(false);
});
test("adds a newly created folder to the creator selection", async () => { test("adds a newly created folder to the creator selection", async () => {
const actionCell = createActionCell(); const actionCell = createActionCell();
const createdFolder = createFolder("folder-3", "重点"); const createdFolder = createFolder("folder-3", "重点");
@@ -273,6 +355,90 @@ describe("favorite-row-picker", () => {
expect(readPopover()).toBeNull(); expect(readPopover()).toBeNull();
}); });
test("clamps a bottom-right picker within the viewport", () => {
const actionCell = createActionCell();
const originalWidth = Object.getOwnPropertyDescriptor(
document.documentElement,
"clientWidth"
);
const originalHeight = Object.getOwnPropertyDescriptor(
document.documentElement,
"clientHeight"
);
Object.defineProperty(document.documentElement, "clientWidth", {
configurable: true,
value: 300
});
Object.defineProperty(document.documentElement, "clientHeight", {
configurable: true,
value: 200
});
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockImplementation(function mockPickerRect(this: HTMLElement): DOMRect {
if (this.dataset.scesFavoriteRowAction === "button") {
return createRect({ bottom: 190, height: 20, left: 280, top: 170, width: 20 });
}
if (this.dataset.scesFavoriteRowPicker === "root") {
return createRect({ height: 60, width: 120 });
}
return createRect({});
});
try {
sync({ actionCell });
readFavoriteButton(actionCell).click();
const root = readPopover() as HTMLElement;
expect(Number.parseFloat(root.style.left)).toBeGreaterThanOrEqual(8);
expect(Number.parseFloat(root.style.left)).toBeLessThanOrEqual(172);
expect(Number.parseFloat(root.style.top)).toBeGreaterThanOrEqual(8);
expect(Number.parseFloat(root.style.top)).toBeLessThanOrEqual(132);
expect(root.style.top).toBe("102px");
} finally {
rectSpy.mockRestore();
if (originalWidth) {
Object.defineProperty(document.documentElement, "clientWidth", originalWidth);
} else {
delete (document.documentElement as { clientWidth?: number }).clientWidth;
}
if (originalHeight) {
Object.defineProperty(document.documentElement, "clientHeight", originalHeight);
} else {
delete (document.documentElement as { clientHeight?: number }).clientHeight;
}
}
});
test("sets trigger dialog state, focuses the picker, and restores focus on Escape", () => {
const actionCell = createActionCell();
sync({ actionCell });
const button = readFavoriteButton(actionCell);
expect(button.getAttribute("aria-haspopup")).toBe("dialog");
expect(button.getAttribute("aria-expanded")).toBe("false");
const pickerId = button.getAttribute("aria-controls");
expect(pickerId).not.toBeNull();
button.focus();
button.click();
const root = readPopover() as HTMLElement;
expect(root.id).toBe(pickerId);
expect(root.getAttribute("role")).toBe("dialog");
expect(root.getAttribute("aria-label")).toBe("选择收藏夹");
expect(button.getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(
document.querySelector(
`input[data-sces-favorite-folder-id="${firstFolder.id}"]`
)
);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(readPopover()).toBeNull();
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(button);
});
test("resyncs one control with fresh callbacks and closes an obsolete popover", async () => { test("resyncs one control with fresh callbacks and closes an obsolete popover", async () => {
const actionCell = createActionCell(); const actionCell = createActionCell();
const staleCallback = vi.fn(async () => {}); const staleCallback = vi.fn(async () => {});