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;
|
resize: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usage-modal-overlay {
|
||||||
|
--overlay-padding-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.usage-modal.modal {
|
.usage-modal.modal {
|
||||||
resize: both;
|
resize: both;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.usage-modal-overlay {
|
||||||
|
--overlay-padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.usage-modal--popover,
|
.usage-modal--popover,
|
||||||
.usage-modal.modal {
|
.usage-modal.modal {
|
||||||
resize: none;
|
resize: none;
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ function getUsageColorClass(percentUsed: number): string {
|
|||||||
const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows";
|
const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows";
|
||||||
const MODAL_SIZE_STORAGE_KEY = "kb-usage-modal-size";
|
const MODAL_SIZE_STORAGE_KEY = "kb-usage-modal-size";
|
||||||
const PROVIDER_ORDER_KEY = "kb-usage-provider-order";
|
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 {
|
interface ModalSize {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -890,12 +893,19 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
|
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
|
||||||
const desktopGap = 8;
|
|
||||||
const maxTopPadding = 12;
|
|
||||||
const defaultPopoverWidth = 420;
|
const defaultPopoverWidth = 420;
|
||||||
const popoverWidth = savedSize?.width ?? defaultPopoverWidth;
|
const popoverWidth = savedSize?.width ?? defaultPopoverWidth;
|
||||||
const desktopTop = showDesktopPopover
|
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;
|
: undefined;
|
||||||
// Anchor popover so its right edge aligns with the anchor button's right edge,
|
// 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.
|
// 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 (
|
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}
|
{usageContent}
|
||||||
</div>
|
</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_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_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_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 {
|
function getWindowIdentity(label: string, index: number): string {
|
||||||
return `${index}::${label}`;
|
return `${index}::${label}`;
|
||||||
@@ -105,6 +120,8 @@ describe("UsageIndicator", () => {
|
|||||||
localStorage.removeItem(USAGE_VIEW_MODE_KEY);
|
localStorage.removeItem(USAGE_VIEW_MODE_KEY);
|
||||||
localStorage.removeItem(USAGE_HIDDEN_WINDOWS_KEY);
|
localStorage.removeItem(USAGE_HIDDEN_WINDOWS_KEY);
|
||||||
localStorage.removeItem(USAGE_PROVIDER_ORDER_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", () => {
|
it("renders nothing when isOpen is false", () => {
|
||||||
@@ -438,7 +455,7 @@ describe("UsageIndicator", () => {
|
|||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
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({
|
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||||
providers: mockProviders,
|
providers: mockProviders,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -459,10 +476,65 @@ describe("UsageIndicator", () => {
|
|||||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||||
expect(modal).toHaveClass("usage-modal--popover");
|
expect(modal).toHaveClass("usage-modal--popover");
|
||||||
expect(modal.style.top).toBe("88px");
|
expect(modal.style.top).toBe("88px");
|
||||||
|
expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2);
|
||||||
expect(modal.style.left).toBe("520px");
|
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({
|
mockUseUsageData.mockReturnValue(createUsageDataState({
|
||||||
providers: mockProviders,
|
providers: mockProviders,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -473,8 +545,67 @@ describe("UsageIndicator", () => {
|
|||||||
|
|
||||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} anchorRect={null} />);
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} anchorRect={null} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("usage-modal")).toHaveClass("modal");
|
const overlay = screen.getByTestId("usage-modal-overlay");
|
||||||
expect(screen.getByTestId("usage-modal")).not.toHaveClass("usage-modal--popover");
|
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", () => {
|
it("calls onClose when overlay is clicked", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user