diff --git a/package.json b/package.json index 5692d7d..3b5a265 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "playwright": "^1.61.1", "prettier": "^3.4.2", "tailwindcss": "^4.1.17", + "tw-animate-css": "^1.4.0", "typescript": "^7.0.0", "vite": "^7.0.0", "vitest": "^4.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09e331c..a164bf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,9 @@ importers: tailwindcss: specifier: ^4.1.17 version: 4.3.3 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^7.0.0 version: 7.0.2 @@ -3856,6 +3859,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8102,6 +8108,8 @@ snapshots: tslib@2.8.1: {} + tw-animate-css@1.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 889d77a..e8fd3c9 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -31,3 +31,39 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { }); await expect.poll(() => riveRequests.length, { timeout: 25_000 }).toBe(1); }); + +test("animates the compact header menu without overflowing", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + + await page.locator('button[aria-label^="查看异常处置面板"]:visible').click(); + + const menu = page.locator('[role="menu"][data-state="open"]'); + await expect(menu).toBeVisible(); + + const animation = await menu.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + duration: Number.parseFloat(style.animationDuration) * 1_000, + name: style.animationName + }; + }); + expect(animation.name).toContain("enter"); + expect(animation.duration).toBeGreaterThan(0); + + const menuBounds = await menu.boundingBox(); + expect(menuBounds).not.toBeNull(); + expect(menuBounds!.x).toBeGreaterThanOrEqual(0); + expect(menuBounds!.x + menuBounds!.width).toBeLessThanOrEqual(375); +}); + +test("renders map notices through a single toaster", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator("[data-sonner-toaster]")).toHaveCount(1); + await page.getByLabel("打开用户菜单").click(); + await page.getByRole("menuitem", { name: /查看运行状态/ }).click(); + + await expect(page.locator("[data-sonner-toast]")).toHaveCount(1); +}); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a146b7c..8adef62 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,5 +1,4 @@ import type { PropsWithChildren } from "react"; -import { Toaster } from "sonner"; import { SWRConfig } from "swr"; async function fetcher(url: string): Promise { @@ -18,7 +17,6 @@ export function AppProviders({ children }: PropsWithChildren) { }} > {children} - ); } diff --git a/src/styles.css b/src/styles.css index d690cec..36285fb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,7 +1,61 @@ @import "tailwindcss"; +@import "tw-animate-css"; @source "../node_modules/streamdown/dist"; @source "../node_modules/@streamdown/*/dist"; +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + --shadow-glass: 0 18px 50px rgba(10, 18, 32, 0.18); +} + +@theme { + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + + to { + height: 0; + } + } +} + :root { color-scheme: light; background: oklch(94.5% 0.012 250);