feat(frontend): Phase 1 UX foundation — react-router v6 + responsive AppShell
Atomic foundation for the consensus-approved UX modernization (makes the platform URL-addressable, refresh-safe, and mobile-usable for hospital demos). - Migrate hand-rolled useState page switching → react-router v6 (routes.tsx, thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route) - Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger (<lg); kills hardcoded ml-[200px]; usable at 375px - Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented) - Sweep all raw "加载中..." text loaders → skeleton primitives (G4) - Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass: deep-link, refresh-preserves-page, back, 375px drawer/no-scroll) - Harden DemographicAnalysis against API shape mismatch (defensive normalize) - gitignore playwright-report/ and test-results/ Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
frontend/src/components/AppShell.tsx
Normal file
56
frontend/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { SideNav } from '@/components/SideNav';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface AppShellProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
|
||||
export function AppShell({ onLogout }: AppShellProps) {
|
||||
const alerts = useRiskStore((s) => s.alerts);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
|
||||
return (
|
||||
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
|
||||
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} />
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* lg 及以上:持久侧栏导轨 */}
|
||||
<aside
|
||||
data-testid={TESTIDS.sidebarRail}
|
||||
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
|
||||
>
|
||||
<SideNav alertCount={alerts.length} />
|
||||
</aside>
|
||||
|
||||
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
|
||||
onClick={closeDrawer}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
data-testid={TESTIDS.appDrawer}
|
||||
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
|
||||
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<SideNav alertCount={alerts.length} onNavigate={closeDrawer} />
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 overflow-auto p-5">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Skeleton } from '@/components/ui';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { geocodedApi } from '@/services/api';
|
||||
@@ -340,7 +341,7 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
|
||||
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{isLoading ? (
|
||||
<span className="text-text-muted">数据加载中...</span>
|
||||
<Skeleton className="h-3 w-20 inline-block align-middle" />
|
||||
) : error ? (
|
||||
<span className="text-danger">加载失败: {error}</span>
|
||||
) : (
|
||||
|
||||
@@ -1,76 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface SideNavProps {
|
||||
activePage: string;
|
||||
onPageChange: (page: string) => void;
|
||||
alertCount?: number;
|
||||
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
testid: string;
|
||||
}
|
||||
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [
|
||||
{
|
||||
id: 'monitoring',
|
||||
label: '监测',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'monitoring', label: '监测面板' },
|
||||
],
|
||||
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
|
||||
},
|
||||
{
|
||||
id: 'alert',
|
||||
label: '预警',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'alerts', label: '预警地图' },
|
||||
],
|
||||
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: '分析',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z" />
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'trend-analysis', label: '趋势分析' },
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
{ id: 'reports', label: '报表中心' },
|
||||
{ id: 'demographics', label: '人群分析' },
|
||||
{ id: 'disease', label: '疾病分析' },
|
||||
{ id: 'environment', label: '环境健康' },
|
||||
{ to: '/overview', label: '总览', testid: TESTIDS.navOverview },
|
||||
{ to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
|
||||
{ to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
|
||||
{ to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
|
||||
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
|
||||
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
|
||||
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
|
||||
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function SideNav({
|
||||
activePage,
|
||||
onPageChange,
|
||||
alertCount = 0,
|
||||
}: SideNavProps) {
|
||||
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
||||
export function SideNav({ alertCount = 0, onNavigate }: SideNavProps) {
|
||||
const location = useLocation();
|
||||
|
||||
const handleItemClick = (moduleId: string, itemId: string) => {
|
||||
setExpanded(moduleId);
|
||||
onPageChange(itemId);
|
||||
};
|
||||
// 当前路径命中的模块默认展开。
|
||||
const moduleForPath = (pathname: string) =>
|
||||
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
||||
|
||||
const [expanded, setExpanded] = useState<string | null>(() => moduleForPath(location.pathname));
|
||||
|
||||
const isActiveModule = (moduleId: string) => {
|
||||
const module = modules.find(m => m.id === moduleId);
|
||||
const module = modules.find((m) => m.id === moduleId);
|
||||
if (!module) return false;
|
||||
return module.items.some(item => item.id === activePage);
|
||||
return module.items.some((item) => location.pathname.startsWith(item.to));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
|
||||
<nav className="h-full overflow-y-auto py-4 px-2">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id} className="mb-4">
|
||||
<button
|
||||
@@ -81,9 +83,7 @@ export function SideNav({
|
||||
: 'text-text-primary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center">
|
||||
{module.icon}
|
||||
</span>
|
||||
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
|
||||
<span>{module.label}</span>
|
||||
{module.id === 'alert' && alertCount > 0 && (
|
||||
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
||||
@@ -95,22 +95,26 @@ export function SideNav({
|
||||
{expanded === module.id && (
|
||||
<div className="mt-1 pl-7">
|
||||
{module.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(module.id, item.id)}
|
||||
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||
activePage === item.id
|
||||
? 'bg-bg-active text-primary'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
data-testid={item.testid}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-bg-active text-primary'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface TopNavProps {
|
||||
onLogout?: () => void;
|
||||
// 移动端汉堡按钮:切换侧栏抽屉。
|
||||
onToggleMenu?: () => void;
|
||||
}
|
||||
|
||||
function Clock() {
|
||||
@@ -13,14 +16,27 @@ function Clock() {
|
||||
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout }: TopNavProps) {
|
||||
|
||||
export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
|
||||
return (
|
||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
|
||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
||||
{onToggleMenu && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMenu}
|
||||
aria-label="打开菜单"
|
||||
data-testid={TESTIDS.hamburger}
|
||||
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
|
||||
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
||||
@@ -28,19 +44,19 @@ export function TopNav({ onLogout }: TopNavProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-border ml-4 mr-4" />
|
||||
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
|
||||
|
||||
<span className="text-[13px] text-text-secondary">
|
||||
<span className="text-[13px] text-text-secondary hidden sm:inline">
|
||||
儿童呼吸道疾病风险监测预警平台
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<span className="text-[12px] text-text-muted">
|
||||
<span className="text-[12px] text-text-muted hidden sm:inline">
|
||||
<Clock />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
admin
|
||||
</div>
|
||||
|
||||
41
frontend/src/components/ui/Card.tsx
Normal file
41
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const Card = memo(function Card({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
actions,
|
||||
testid,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
testid?: string;
|
||||
}): JSX.Element {
|
||||
const hasHeader = title != null || actions != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={testid}
|
||||
className={[
|
||||
'bg-bg-card rounded-lg border border-border',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border-light">
|
||||
{title != null && (
|
||||
<div className="text-sm font-medium text-text-primary">{title}</div>
|
||||
)}
|
||||
{actions != null && (
|
||||
<div className="flex items-center gap-2 shrink-0">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
29
frontend/src/components/ui/EmptyState.tsx
Normal file
29
frontend/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const EmptyState = memo(function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="empty-state"
|
||||
className="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center"
|
||||
>
|
||||
{icon && (
|
||||
<div className="text-text-muted text-4xl">{icon}</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-text-secondary">{title}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-text-muted max-w-xs">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
30
frontend/src/components/ui/LoadingState.tsx
Normal file
30
frontend/src/components/ui/LoadingState.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from './Skeleton';
|
||||
|
||||
export function LoadingState({
|
||||
label,
|
||||
testid,
|
||||
lines = 3,
|
||||
}: {
|
||||
label?: string;
|
||||
testid?: string;
|
||||
lines?: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid={testid ?? 'loading-state'}
|
||||
className="flex flex-col items-center justify-center gap-3 p-6 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full max-w-sm">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{label && (
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/Panel.tsx
Normal file
22
frontend/src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const Panel = memo(function Panel({
|
||||
children,
|
||||
className,
|
||||
testid,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
testid?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid={testid}
|
||||
className={['bg-bg-hover/50 rounded-md p-3', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
53
frontend/src/components/ui/Segmented.tsx
Normal file
53
frontend/src/components/ui/Segmented.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Segmented = memo(function Segmented<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
size = 'md',
|
||||
testid,
|
||||
}: {
|
||||
options: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
size?: 'sm' | 'md';
|
||||
testid?: string;
|
||||
}): JSX.Element {
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'px-2.5 py-0.5 text-xs'
|
||||
: 'px-3.5 py-1 text-[13px]';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={testid}
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isActive = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-testid={testid ? `${testid}-${opt.value}` : undefined}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={[
|
||||
'rounded-full font-medium transition-colors',
|
||||
sizeClasses,
|
||||
isActive
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-text-secondary hover:bg-bg-active',
|
||||
].join(' ')}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}) as <T extends string>(props: {
|
||||
options: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
size?: 'sm' | 'md';
|
||||
testid?: string;
|
||||
}) => JSX.Element;
|
||||
22
frontend/src/components/ui/Skeleton.tsx
Normal file
22
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Skeleton = memo(function Skeleton({
|
||||
className,
|
||||
rounded,
|
||||
}: {
|
||||
className?: string;
|
||||
rounded?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="skeleton"
|
||||
className={[
|
||||
'animate-pulse bg-bg-hover',
|
||||
rounded ? 'rounded-full' : 'rounded',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
);
|
||||
});
|
||||
6
frontend/src/components/ui/index.ts
Normal file
6
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Skeleton } from './Skeleton';
|
||||
export { LoadingState } from './LoadingState';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { Card } from './Card';
|
||||
export { Panel } from './Panel';
|
||||
export { Segmented } from './Segmented';
|
||||
Reference in New Issue
Block a user