Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ is installed without a click.
credential. A failed card has one compact warning icon beside its first quota
title, with no permanent failure sentence or stale badge. Hover, keyboard
focus, or click opens the safe reason and last successful update; Escape,
focus leaving, or an outside click closes it. Old percentages say "Last" and
focus leaving, or an outside click closes it. Details open above the icon
when needed and stay within the window as it scrolls or resizes. Old
percentages say "Last" and
use muted meters without reset countdowns or replacement explanation lines.
An unavailable result stays unknown. Stale data without a failure says
"Quota awaiting update" in its details rather than claiming a failed refresh.
Expand Down
3 changes: 2 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ as applicable:
- cached and uncached refresh failures showing at most one quota warning per
card, historical percentages and muted meters without a stale badge or
countdown, safe failure details and the last successful time, hover/click/
keyboard access and Escape/blur/outside dismissal, stale data without a
keyboard access and Escape/blur/outside dismissal, details remaining inside
the window at its edges and after scroll/resize, stale data without a
failure described as awaiting an update, authentication recovery, and a
successful retry clearing the warning and historical labels;
- reopening with a successful reading older than five minutes keeping its
Expand Down
42 changes: 42 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,48 @@ describe("GSwitch account workspace", () => {
expect(within(card).queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument();
});

it("keeps quota details inside the window when the warning is near an edge or the page scrolls", async () => {
vi.stubGlobal("innerWidth", 700);
vi.stubGlobal("innerHeight", 520);
let anchor = new DOMRect(665, 480, 22, 22);
const originalRect = HTMLElement.prototype.getBoundingClientRect;
const measure = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
if (this.classList.contains("quota-alert")) return anchor;
if (this.classList.contains("quota-alert-tooltip")) {
return new DOMRect(anchor.x + parseFloat(this.style.left || "-4"), anchor.y + parseFloat(this.style.top || "26"), 250, 130);
}
return originalRect.call(this);
});
try {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue(staleQuota);
mocks.refreshAccountQuota.mockRejectedValue({ code: "network" });
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: "Quota update failed for person@example.com" }));
const details = screen.getByRole("tooltip");
let bounds = details.getBoundingClientRect();
expect(bounds.bottom).toBeLessThan(anchor.top);
expect(bounds.right).toBeLessThanOrEqual(window.innerWidth - 8);
expect(bounds.left).toBeGreaterThanOrEqual(8);

anchor = new DOMRect(16, 16, 22, 22);
fireEvent.scroll(window);
bounds = details.getBoundingClientRect();
expect(bounds.top).toBeGreaterThan(anchor.bottom);
expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight - 8);
expect(bounds.left).toBeGreaterThanOrEqual(8);

anchor = new DOMRect(665, 480, 22, 22);
fireEvent.resize(window);
bounds = details.getBoundingClientRect();
expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight - 8);
expect(bounds.right).toBeLessThanOrEqual(window.innerWidth - 8);
} finally {
measure.mockRestore();
vi.unstubAllGlobals();
}
});

it("describes stale data without a failure as awaiting an update and omits an invalid last-success time", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue({
Expand Down
28 changes: 27 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -685,6 +686,31 @@ function QuotaAlert({ accountName, failure, id, lastSuccess, formatLocale, t }:
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLSpanElement>(null);
const detailsRef = useRef<HTMLSpanElement>(null);
const [position, setPosition] = useState({ top: 26, left: -4 });

useLayoutEffect(() => {
if (!open) return;
const place = () => {
if (!rootRef.current || !detailsRef.current) return;
const anchor = rootRef.current.getBoundingClientRect();
const details = detailsRef.current.getBoundingClientRect();
const below = anchor.bottom + 4;
const above = anchor.top - details.height - 4;
const top = below + details.height <= window.innerHeight - 8 ? below
: above >= 8 ? above : Math.max(8, window.innerHeight - details.height - 8);
const left = Math.max(8, Math.min(anchor.left - 4, window.innerWidth - details.width - 8));
const next = { top: top - anchor.top, left: left - anchor.left };
setPosition((current) => current.top === next.top && current.left === next.left ? current : next);
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open, failure, lastSuccess, formatLocale, t]);

useEffect(() => {
if (!open) return;
Expand Down Expand Up @@ -730,7 +756,7 @@ function QuotaAlert({ accountName, failure, id, lastSuccess, formatLocale, t }:
onClick={() => setOpen(true)}
type="button"
><CircleAlert size={15} /></button>
<span className="quota-alert-tooltip" hidden={!open} id={id} role="tooltip">
<span className="quota-alert-tooltip" hidden={!open} id={id} ref={detailsRef} role="tooltip" style={position}>
<strong>{t(failure ? "quota.refreshErrorLabel" : "quota.pending")}</strong>
{failure ? <span>{quotaFailureMessage(t, failure)}</span> : null}
{lastSuccess && lastSuccess > 0 && lastSuccess <= Date.now() ? (
Expand Down
Loading