feat: implement new layout components and routing structure
- Added HeaderLayout component for the application header. - Introduced KeepAliveViewport for managing tab states and rendering. - Created TabNav for tab navigation with animated indicator. - Removed old Tabs component in favor of new layout structure. - Updated routing with AppRouter and defined appTabs for navigation. - Enhanced theme context to manage dark mode styles. - Added new UI components: Badge, Button, Separator, and Tabs. - Refactored pages to utilize new layout components and improve responsiveness. - Updated global styles for better theming and layout consistency. - Introduced TypeScript path aliases for cleaner imports.
This commit is contained in:
125
frontend/src/components/layout/TabNav.tsx
Normal file
125
frontend/src/components/layout/TabNav.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { AppTabConfig, TabId } from '../../router/tabs';
|
||||
import { appTabs } from '../../router/tabs';
|
||||
|
||||
interface TabNavProps {
|
||||
activeTab: AppTabConfig;
|
||||
}
|
||||
|
||||
interface IndicatorStyle {
|
||||
opacity: number;
|
||||
transform: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
export function TabNav({ activeTab }: TabNavProps) {
|
||||
const navigate = useNavigate();
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRefs = useRef<Record<TabId, HTMLButtonElement | null>>({
|
||||
perception: null,
|
||||
docs: null,
|
||||
compliance: null,
|
||||
status: null,
|
||||
rag: null,
|
||||
});
|
||||
const [indicatorStyle, setIndicatorStyle] = useState<IndicatorStyle>({
|
||||
opacity: 0,
|
||||
transform: 'translateX(0px)',
|
||||
width: 0,
|
||||
});
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
const nextTab = appTabs.find((tab) => tab.id === value);
|
||||
if (nextTab && nextTab.path !== activeTab.path) {
|
||||
navigate(nextTab.path);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(reducedMotionQuery);
|
||||
const updateMotionPreference = () => {
|
||||
setReducedMotion(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateMotionPreference();
|
||||
mediaQuery.addEventListener('change', updateMotionPreference);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateMotionPreference);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const updateIndicator = () => {
|
||||
const trackNode = trackRef.current;
|
||||
const activeNode = buttonRefs.current[activeTab.id];
|
||||
if (!trackNode || !activeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackRect = trackNode.getBoundingClientRect();
|
||||
const activeRect = activeNode.getBoundingClientRect();
|
||||
|
||||
setIndicatorStyle({
|
||||
opacity: 1,
|
||||
transform: `translateX(${activeRect.left - trackRect.left}px)`,
|
||||
width: activeRect.width,
|
||||
});
|
||||
};
|
||||
|
||||
updateIndicator();
|
||||
window.addEventListener('resize', updateIndicator);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateIndicator);
|
||||
};
|
||||
}, [activeTab.id]);
|
||||
|
||||
return (
|
||||
<nav className="flex h-full min-w-0 items-stretch overflow-x-auto overflow-y-hidden">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative flex h-full min-w-max flex-nowrap items-stretch gap-3 pr-6"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'pointer-events-none absolute bottom-0 left-0 h-0.5 rounded-full bg-primary',
|
||||
reducedMotion
|
||||
? 'transition-none'
|
||||
: 'transition-[transform,width,opacity] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
].join(' ')}
|
||||
style={indicatorStyle}
|
||||
/>
|
||||
{appTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(node) => {
|
||||
buttonRefs.current[tab.id] = node;
|
||||
}}
|
||||
data-shell-tab="true"
|
||||
type="button"
|
||||
onClick={() => handleValueChange(tab.id satisfies TabId)}
|
||||
aria-current={tab.id === activeTab.id ? 'page' : undefined}
|
||||
className={[
|
||||
'inline-flex h-full shrink-0 appearance-none items-center justify-center border-0 border-b-2 border-transparent bg-transparent px-5 pt-1 text-[0.95rem] font-medium tracking-[0.02em] outline-none',
|
||||
reducedMotion
|
||||
? 'transition-none'
|
||||
: 'transition-[color,opacity] duration-200 ease-out',
|
||||
tab.id === activeTab.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user