feat(web): polish mobile status drawer (#1929)
* feat(web): polish mobile status drawer * fix(web): preserve mobile metrics row width
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconActivity } from "@tabler/icons-react";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import { useAppStatusItems, type AppStatusItem } from "./app-status-items";
|
||||
import { useAppStatusBarOrder } from "./use-app-status-bar-order";
|
||||
|
||||
@@ -29,19 +31,48 @@ export function AppStatusDrawer({
|
||||
const activeItems = useAppStatusItems(context);
|
||||
const { projected } = useAppStatusBarOrder(activeItems);
|
||||
const orderedItems = [...projected.left, ...projected.right];
|
||||
let drawerHeight = "h-[min(32rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]";
|
||||
if (orderedItems.length <= 1) {
|
||||
drawerHeight = "h-[min(15rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]";
|
||||
} else if (orderedItems.length === 2) {
|
||||
drawerHeight = "h-[min(20rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]";
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[min(32rem,calc(100dvh-16px))] max-h-[calc(100dvh-16px)] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
<DrawerContent
|
||||
className={cn(
|
||||
drawerHeight,
|
||||
"max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] overflow-hidden pb-[max(0.75rem,env(safe-area-inset-bottom))] before:rounded-[20px]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="app-status-drawer"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-card text-card-foreground antialiased [font-family:var(--font-geist-sans)] shadow-[0_1px_2px_rgb(0_0_0_/_0.04),0_0_0_1px_rgb(0_0_0_/_0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255_/_0.08)]"
|
||||
>
|
||||
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
|
||||
<DrawerTitle>Status</DrawerTitle>
|
||||
<DrawerHeader
|
||||
data-testid="app-status-drawer-summary"
|
||||
className="shrink-0 border-b border-border/80 px-5 pb-4 pt-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary shadow-[0_1px_2px_rgb(0_0_0_/_0.04)]">
|
||||
<IconActivity className="size-4" stroke={1.8} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Workspace
|
||||
</p>
|
||||
<DrawerTitle className="text-base font-semibold tracking-[-0.015em] text-balance">
|
||||
Status
|
||||
</DrawerTitle>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
<section className="space-y-1" aria-label="Application status items">
|
||||
<div
|
||||
data-testid="app-status-drawer-scroll-region"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3"
|
||||
>
|
||||
<section className="space-y-2" aria-label="Application status items">
|
||||
{orderedItems.map((item) => (
|
||||
<DrawerStatusItem key={item.id} item={item} open={open} />
|
||||
))}
|
||||
@@ -56,7 +87,7 @@ export function AppStatusDrawer({
|
||||
function DrawerStatusItem({ item, open }: { item: AppStatusItem; open: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-11 w-full min-w-0 items-center rounded-md px-3 hover:bg-muted/60 empty:hidden"
|
||||
className="flex min-h-11 w-full min-w-0 items-center rounded-lg bg-muted/50 px-3 py-2 shadow-[0_1px_2px_rgb(0_0_0_/_0.03),0_0_0_1px_rgb(0_0_0_/_0.04)] transition-[background-color,box-shadow] duration-150 ease-out hover:bg-muted/80 focus-within:bg-muted/80 focus-within:shadow-[0_1px_2px_rgb(0_0_0_/_0.06),0_0_0_1px_rgb(0_0_0_/_0.06)] empty:hidden dark:shadow-[0_0_0_1px_rgb(255_255_255_/_0.06)]"
|
||||
data-status-item-id={item.id}
|
||||
>
|
||||
{item.render({ presentation: "mobile-drawer", density: "full", drawerOpen: open })}
|
||||
|
||||
@@ -76,9 +76,16 @@ export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "
|
||||
className={`size-1.5 shrink-0 rounded-full ${details.dotClass} ${details.animate ? "animate-pulse" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={presentation === "bar" ? "sr-only" : "text-foreground"}>
|
||||
{presentation === "bar" ? details.label : details.description}
|
||||
</span>
|
||||
{presentation === "bar" ? (
|
||||
<span className="sr-only">{details.label}</span>
|
||||
) : (
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Connection
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{details.description}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{details.description}</TooltipContent>
|
||||
|
||||
@@ -146,7 +146,10 @@ describe("StatusSurfaceMetrics", () => {
|
||||
renderMetrics(true);
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledWith(true);
|
||||
expect(screen.getByLabelText("Host metrics").parentElement?.className).toContain("min-h-11");
|
||||
const metricsRow = screen.getByLabelText("Host metrics").parentElement;
|
||||
expect(metricsRow?.className).toContain("min-h-11");
|
||||
expect(metricsRow?.className).toContain("w-full");
|
||||
expect(metricsRow?.className).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("omits the host marker and meters in simplified desktop mode", () => {
|
||||
|
||||
@@ -40,8 +40,14 @@ export function StatusSurfaceMetrics({
|
||||
const host = snapshot?.sources.find((source) => source.kind === "backend");
|
||||
if (presentation === "mobile-drawer") {
|
||||
return (
|
||||
<section data-testid="app-status-metrics" className="space-y-1" aria-label="System metrics">
|
||||
<h3 className="px-1 text-sm font-medium">System metrics</h3>
|
||||
<section
|
||||
data-testid="app-status-metrics"
|
||||
className="space-y-2 py-0.5"
|
||||
aria-label="System metrics"
|
||||
>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
System metrics
|
||||
</h3>
|
||||
{!host ? (
|
||||
<EmptyMetrics drawer />
|
||||
) : (
|
||||
@@ -129,7 +135,7 @@ function DrawerSourceMetrics({
|
||||
simplified: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-11 items-center gap-2 rounded-md px-3 text-sm hover:bg-muted/60">
|
||||
<div className="flex min-h-11 w-full min-w-0 items-center gap-2 px-0 text-sm">
|
||||
{!simplified ? <SourceBadge source={source} updatedAt={updatedAt} showLabel /> : null}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-3 overflow-hidden">
|
||||
<MetricValues source={source} updatedAt={updatedAt} limit={4} simplified={simplified} />
|
||||
|
||||
@@ -22,6 +22,24 @@ test.describe("Mobile Status drawer", () => {
|
||||
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
|
||||
});
|
||||
|
||||
test("keeps a single status signal compact", async ({ testPage, apiClient }) => {
|
||||
const settingsResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
system_metrics_display: { show_in_topbar: false },
|
||||
});
|
||||
expect(settingsResponse.ok).toBe(true);
|
||||
|
||||
await testPage.goto("/stats");
|
||||
await testPage.getByTestId("app-status-drawer-trigger").click();
|
||||
|
||||
const drawer = testPage.getByTestId("app-status-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.locator("[data-status-item-id]")).toHaveCount(1);
|
||||
const dialog = testPage.getByRole("dialog", { name: "Status" });
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
if (!dialogBox) throw new Error("single-signal Status drawer has no geometry");
|
||||
expect(dialogBox.height).toBeLessThan(300);
|
||||
});
|
||||
|
||||
test("opens native Status paths without a persistent phone footer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -51,6 +69,10 @@ test.describe("Mobile Status drawer", () => {
|
||||
|
||||
const drawer = testPage.getByTestId("app-status-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
const summary = drawer.getByTestId("app-status-drawer-summary");
|
||||
await expect(summary.getByRole("heading", { name: "Status" })).toBeVisible();
|
||||
await expect(summary).not.toContainText("Connection, system health, and workspace signals.");
|
||||
await expect(drawer.getByTestId("app-status-drawer-scroll-region")).toBeVisible();
|
||||
await expect(drawer.getByTestId("app-status-connection")).toBeVisible();
|
||||
await expect(testPage.locator("#hello-status-left")).toContainText("mobile-drawer no-task");
|
||||
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
|
||||
@@ -94,10 +116,12 @@ test.describe("Mobile Status drawer", () => {
|
||||
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
|
||||
await testPage.evaluate(() => document.documentElement.clientWidth),
|
||||
);
|
||||
// Chromium may consume Escape immediately after the synthetic modifier drag.
|
||||
await testPage
|
||||
.locator('[data-slot="drawer-overlay"][data-state="open"]')
|
||||
.click({ position: { x: 4, y: 4 } });
|
||||
// A vertical modifier drag can resolve as Vaul's native swipe-to-dismiss.
|
||||
// Either dismissal path must leave phone ordering untouched.
|
||||
const overlay = testPage.locator('[data-slot="drawer-overlay"][data-state="open"]');
|
||||
if (await overlay.isVisible()) {
|
||||
await overlay.click({ position: { x: 4, y: 4 } });
|
||||
}
|
||||
await expect(drawer).toBeHidden();
|
||||
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Mobile status task", {
|
||||
|
||||
Reference in New Issue
Block a user