126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|