FN-6223: keep usage dialog near the top
Keep usage dialog positioning consistently top-biased across viewport surfaces. - Clamp desktop popover placement to the upper viewport while preserving anchor alignment and saved sizing. - Add overlay styling so modal and mobile sheet presentations start near the top. - Extend UsageIndicator tests across desktop, mobile, saved-size, and data-state surfaces. - Add a patch changeset for the published Fusion package. Files changed: .changeset/top-usage-dialog.md | 5 + .../dashboard/app/components/UsageIndicator.css | 8 ++ .../dashboard/app/components/UsageIndicator.tsx | 18 ++- .../components/__tests__/UsageIndicator.test.tsx | 139 ++++++++++++++++++++- 4 files changed, 162 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6223 Fusion-Task-Lineage: a35e82f4-ddef-4d68-81f0-6fee420f0392
This commit is contained in:
5
.changeset/top-usage-dialog.md
Normal file
5
.changeset/top-usage-dialog.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Keep the dashboard usage dialog near the top of the viewport across desktop popover, modal, and mobile presentations.
|
||||
@@ -621,12 +621,20 @@
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.usage-modal-overlay {
|
||||
--overlay-padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.usage-modal.modal {
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.usage-modal-overlay {
|
||||
--overlay-padding-top: 0;
|
||||
}
|
||||
|
||||
.usage-modal--popover,
|
||||
.usage-modal.modal {
|
||||
resize: none;
|
||||
|
||||
@@ -85,6 +85,9 @@ function getUsageColorClass(percentUsed: number): string {
|
||||
const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows";
|
||||
const MODAL_SIZE_STORAGE_KEY = "kb-usage-modal-size";
|
||||
const PROVIDER_ORDER_KEY = "kb-usage-provider-order";
|
||||
const DESKTOP_POPOVER_GAP = 8;
|
||||
const DESKTOP_POPOVER_TOP_INSET = DESKTOP_POPOVER_GAP * 2;
|
||||
const DESKTOP_POPOVER_MAX_TOP_VIEWPORT_RATIO = 0.25;
|
||||
|
||||
interface ModalSize {
|
||||
width: number;
|
||||
@@ -890,12 +893,19 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
if (!isOpen) return null;
|
||||
|
||||
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
|
||||
const desktopGap = 8;
|
||||
const maxTopPadding = 12;
|
||||
const defaultPopoverWidth = 420;
|
||||
const popoverWidth = savedSize?.width ?? defaultPopoverWidth;
|
||||
const desktopTop = showDesktopPopover
|
||||
? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding)
|
||||
? Math.max(
|
||||
DESKTOP_POPOVER_TOP_INSET,
|
||||
Math.min(
|
||||
(anchorRect?.bottom ?? 0) + DESKTOP_POPOVER_GAP,
|
||||
Math.max(
|
||||
DESKTOP_POPOVER_TOP_INSET,
|
||||
window.innerHeight * DESKTOP_POPOVER_MAX_TOP_VIEWPORT_RATIO
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
// Anchor popover so its right edge aligns with the anchor button's right edge,
|
||||
// but use `left` positioning so native resize (bottom-right handle) feels natural.
|
||||
@@ -1052,7 +1062,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
|
||||
<div className="modal-overlay open usage-modal-overlay" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
|
||||
{usageContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,21 @@ function createAnchorRect(partial: Partial<DOMRect> = {}): DOMRect {
|
||||
const USAGE_VIEW_MODE_KEY = scopedKey("kb-usage-view-mode", TEST_PROJECT_ID);
|
||||
const USAGE_HIDDEN_WINDOWS_KEY = scopedKey("kb-usage-hidden-windows", TEST_PROJECT_ID);
|
||||
const USAGE_PROVIDER_ORDER_KEY = scopedKey("kb-usage-provider-order", TEST_PROJECT_ID);
|
||||
const USAGE_MODAL_SIZE_KEY = scopedKey("kb-usage-modal-size", TEST_PROJECT_ID);
|
||||
|
||||
function setViewportSize({ width, height }: { width: number; height: number }) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: height,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
|
||||
function getWindowIdentity(label: string, index: number): string {
|
||||
return `${index}::${label}`;
|
||||
@@ -105,6 +120,8 @@ describe("UsageIndicator", () => {
|
||||
localStorage.removeItem(USAGE_VIEW_MODE_KEY);
|
||||
localStorage.removeItem(USAGE_HIDDEN_WINDOWS_KEY);
|
||||
localStorage.removeItem(USAGE_PROVIDER_ORDER_KEY);
|
||||
localStorage.removeItem(USAGE_MODAL_SIZE_KEY);
|
||||
setViewportSize({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it("renders nothing when isOpen is false", () => {
|
||||
@@ -438,7 +455,7 @@ describe("UsageIndicator", () => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders as popover below anchor on desktop when anchorRect provided", () => {
|
||||
it("renders as top-biased popover below a high desktop anchor", () => {
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
@@ -459,10 +476,65 @@ describe("UsageIndicator", () => {
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(modal).toHaveClass("usage-modal--popover");
|
||||
expect(modal.style.top).toBe("88px");
|
||||
expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2);
|
||||
expect(modal.style.left).toBe("520px");
|
||||
});
|
||||
|
||||
it("renders as full-screen modal when anchorRect is null", () => {
|
||||
it("keeps the desktop popover near the top on a short viewport", () => {
|
||||
setViewportSize({ width: 1024, height: 240 });
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
}));
|
||||
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect()}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(modal).toHaveClass("usage-modal--popover");
|
||||
expect(modal.style.top).toBe("60px");
|
||||
expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2);
|
||||
});
|
||||
|
||||
it("clamps the desktop popover near the top for a low anchor while preserving saved size", () => {
|
||||
setViewportSize({ width: 1024, height: 800 });
|
||||
localStorage.setItem(USAGE_MODAL_SIZE_KEY, JSON.stringify({ width: 600, height: 500 }));
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
}));
|
||||
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect({ top: 620, bottom: 650 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(modal).toHaveClass("usage-modal--popover");
|
||||
expect(modal.style.top).toBe("200px");
|
||||
expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2);
|
||||
expect(modal.style.left).toBe("340px");
|
||||
expect(modal.style.width).toBe("600px");
|
||||
expect(modal.style.height).toBe("500px");
|
||||
});
|
||||
|
||||
it("renders as top-aligned full-screen modal when anchorRect is null", () => {
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
@@ -473,8 +545,67 @@ describe("UsageIndicator", () => {
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} anchorRect={null} />);
|
||||
|
||||
expect(screen.getByTestId("usage-modal")).toHaveClass("modal");
|
||||
expect(screen.getByTestId("usage-modal")).not.toHaveClass("usage-modal--popover");
|
||||
const overlay = screen.getByTestId("usage-modal-overlay");
|
||||
const modal = screen.getByTestId("usage-modal");
|
||||
expect(overlay).toHaveClass("modal-overlay", "open", "usage-modal-overlay");
|
||||
expect(modal).toHaveClass("modal");
|
||||
expect(modal).not.toHaveClass("usage-modal--popover");
|
||||
expect(modal.parentElement).toBe(overlay);
|
||||
});
|
||||
|
||||
it("uses the top-aligned mobile sheet surface instead of the desktop popover", () => {
|
||||
setViewportSize({ width: 768, height: 600 });
|
||||
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
}));
|
||||
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect()}
|
||||
/>
|
||||
);
|
||||
|
||||
const overlay = screen.getByTestId("usage-modal-overlay");
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(overlay).toHaveClass("usage-modal-overlay");
|
||||
expect(modal).toHaveClass("usage-modal", "modal");
|
||||
expect(modal).not.toHaveClass("usage-modal--popover");
|
||||
expect(modal.style.top).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["populated", createUsageDataState({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh }), "Anthropic"],
|
||||
["empty", createUsageDataState({ providers: [], loading: false, error: null, lastUpdated: null, refresh: mockRefresh }), "No AI providers configured"],
|
||||
["loading", createUsageDataState({ providers: [], loading: true, error: null, lastUpdated: null, hasFetched: false, refresh: mockRefresh }), null],
|
||||
["error", createUsageDataState({ providers: [], loading: false, error: "Failed to fetch usage data", lastUpdated: null, refresh: mockRefresh }), "Failed to load usage data"],
|
||||
])("keeps top-biased popover positioning for %s content", (_stateName, usageState, expectedText) => {
|
||||
setViewportSize({ width: 1024, height: 240 });
|
||||
mockUseUsageData.mockReturnValue(usageState);
|
||||
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect({ top: 180, bottom: 210 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(modal).toHaveClass("usage-modal--popover");
|
||||
expect(modal.style.top).toBe("60px");
|
||||
if (expectedText) {
|
||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||
} else {
|
||||
expect(document.querySelector(".usage-skeleton")).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("calls onClose when overlay is clicked", () => {
|
||||
|
||||
Reference in New Issue
Block a user