Refine status item placement and inactive styling

This commit is contained in:
vorotamoroz committed 2026-10-25 09:03:51 +00:00
1 parent 68dc40ff05
commit e7f7e62cfa
9 files changed
+345 -68

No files matched your search

@@ -161,7 +161,7 @@ export function projectReplicationStatusLabel(
fastForwardCount > 0
? fastForwardCount
: undefined;
const fastForwardSuffix = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
const parts: StatusBarPart[] = [];
if (networkStatus.trackedRequestCount > 0) {
@@ -177,10 +177,6 @@ export function projectReplicationStatusLabel(
parts.push({ key: "network", text: networkActivity });
}
parts.push({ key: "sync", text: "Sync: " + icon });
parts.push({ key: "sent", text: " ↑ ", value: sent });
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
if (visibleFastForwardCount !== undefined) {
parts.push({
key: "ff",
@@ -190,6 +186,10 @@ export function projectReplicationStatusLabel(
title: "Compared CouchDB feed entries",
});
}
parts.push({ key: "sent", text: " ↑ ", value: sent });
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
return {
icon,
@@ -197,13 +197,13 @@ export function projectReplicationStatusLabel(
networkActivity +
"Sync: " +
icon +
fastForwardLabel +
" ↑ " +
sent +
pushRemaining.text +
" ↓ " +
arrived +
pullRemaining.text +
fastForwardSuffix,
pullRemaining.text,
parts,
};
}
@@ -126,8 +126,9 @@ describe("projectReplicationStatusLabel", () => {
1200
);
expect(transferred.icon).toBe("⚡");
expect(transferred.message).toBe("Sync: ⚡ ↑ 0 ↓ 7 (⏩ 1200)");
expect(transferred.parts[transferred.parts.length - 1]).toMatchObject({
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
expect(transferred.parts[1]).toMatchObject({
key: "ff",
text: " (⏩ ",
value: 1200,
@@ -144,7 +145,7 @@ describe("projectReplicationStatusLabel", () => {
1200
);
expect(idle.icon).toBe("⚡");
expect(idle.message).toBe("Sync: ⚡ ↑ 0 ↓ 7 (⏩ 1200)");
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
});
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
+17 -16
View File
@@ -1,3 +1,5 @@
const INACTIVE_CLASS = "livesync-stat-inactive";
export type StatusBarPart = {
key: string;
text: string;
@@ -22,7 +24,6 @@ type RenderedItem = {
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
const items = new Map<string, RenderedItem>();
const managedElements = new Set<HTMLSpanElement>();
let disposed = false;
const setText = (node: Text, text: string) => {
@@ -33,6 +34,13 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
if (element.hidden !== hidden) element.hidden = hidden;
};
const setInactive = (element: HTMLElement, inactive: boolean) => {
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
element.classList.toggle(INACTIVE_CLASS, inactive);
if (inactive) element.setAttribute("aria-hidden", "true");
else element.removeAttribute("aria-hidden");
};
const setWidth = (element: HTMLElement, width: string) => {
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
element.setCssProps({ "--livesync-stat-width": width });
@@ -58,7 +66,6 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
items.set(key, item);
managedElements.add(element);
return item;
};
@@ -89,20 +96,16 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
};
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
const desiredOrder = visibleItems.map((item) => item.element);
const visibleElements = new Set(desiredOrder);
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
managedElements.has(node as HTMLSpanElement)
visibleElements.has(node as HTMLSpanElement)
);
const visibleElements = new Set(visibleItems.map((item) => item.element));
const desiredOrder = [
...visibleItems.map((item) => item.element),
...currentOrder.filter((element) => !visibleElements.has(element)),
];
if (
currentOrder.length === desiredOrder.length &&
currentOrder.every((node, index) => node === desiredOrder[index])
) {
)
return;
}
let reference: ChildNode | null = currentOrder[0] ?? null;
for (const element of desiredOrder) {
@@ -110,7 +113,7 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
const childNodes = Array.from(parent.childNodes);
const elementIndex = childNodes.indexOf(element);
reference =
childNodes.slice(elementIndex + 1).find((node) => managedElements.has(node as HTMLSpanElement)) ?? null;
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
}
};
@@ -123,17 +126,16 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
for (const part of parts) {
let item = items.get(part.key);
if (!item) item = createItem(part.key);
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
updateItem(item, part);
setHidden(item.element, false);
setInactive(item.element, false);
visibleItems.push(item);
visibleKeys.add(part.key);
}
for (const [key, item] of items) {
if (visibleKeys.has(key)) continue;
setHidden(item.element, true);
setWidth(item.value, "");
item.widestValueLength = 0;
setInactive(item.element, true);
}
reorderIfNeeded(visibleItems);
@@ -145,7 +147,6 @@ export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer
if (item.element.parentNode === parent) parent.removeChild(item.element);
}
items.clear();
managedElements.clear();
},
};
}
@@ -66,6 +66,33 @@ class TestElement {
private currentClassName = "";
private currentHidden = false;
private currentTitle = "";
private attributes = new Map<string, string>();
readonly classList = {
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
toggle: (token: string, force: boolean): boolean => {
const tokens = this.currentClassName.split(" ").filter(Boolean);
const present = tokens.includes(token);
if (present !== force) {
this.className = force
? [...tokens, token].join(" ")
: tokens.filter((item) => item !== token).join(" ");
}
return force;
},
};
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
setAttribute(name: string, value: string) {
this.document.mutations++;
this.attributes.set(name, value);
}
removeAttribute(name: string) {
if (this.attributes.delete(name)) this.document.mutations++;
}
constructor(
private readonly document: TestDocument,
@@ -173,7 +200,9 @@ function findValue(item: TestElement): TestElement {
}
function visibleItems(root: TestElement): TestElement[] {
return root.children.filter((child) => !child.hidden && child.className.split(" ").includes("livesync-stat-item"));
return root.children.filter(
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
);
}
describe("createStatusBarRenderer", () => {
@@ -195,8 +224,10 @@ describe("createStatusBarRenderer", () => {
expect(findValue(queue).className).toBe("livesync-stat-value");
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
expect(sync.hidden).toBe(true);
expect(queue.hidden).toBe(false);
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
expect(sync.hidden).toBe(false);
expect(sync.getAttribute("aria-hidden")).toBe("true");
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
expect(queue.textContent).toBe(" (queued: 3)");
renderer.render([{ key: "queue", text: "Queue idle" }]);
expect(queue.textContent).toBe("Queue idle");
@@ -239,7 +270,7 @@ describe("createStatusBarRenderer", () => {
renderer.dispose();
});
it("resets numeric width while omitted and reuses the same item when it returns", () => {
it("retains omitted content and width, then resets width when the same item returns", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
@@ -249,16 +280,49 @@ describe("createStatusBarRenderer", () => {
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([]);
expect(item.hidden).toBe(true);
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("");
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
expect(item.hidden).toBe(false);
expect(item.getAttribute("aria-hidden")).toBe("true");
expect(item.textContent).toBe(" 1234");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([{ key: "count", text: " ", value: 5 }]);
expect(findItem(root, "count")).toBe(item);
expect(item.hidden).toBe(false);
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
expect(item.getAttribute("aria-hidden")).toBeNull();
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
renderer.dispose();
});
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
const { document, root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
const first = { key: "first", text: "first " };
const count = { key: "count", text: "count ", value: 1234 };
const last = { key: "last", text: "last" };
renderer.render([first, count, last]);
const initialOrder = [...root.children];
const omitted = findItem(root, "count");
renderer.render([first, last]);
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
const mutations = document.mutations;
renderer.render([first, last]);
expect(document.mutations).toBe(mutations);
const added = { key: "added", text: "added " };
renderer.render([first, added, last]);
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
expect(omitted.textContent).toBe("count 1234");
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([first, count, added, last]);
expect(findItem(root, "count")).toBe(omitted);
expect(visibleItems(root)).toEqual(root.children);
renderer.dispose();
});
it("reorders existing nodes without replacing them", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);