mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
feat(frontend): button tabs in the page top bar; App Map views move into it
This commit is contained in:
1 parent
72169e9de4
commit
3f8b6fb878
3 files changed
+103
-51
No files matched your search
@@ -51,6 +51,9 @@ export function PageTopbar({ icon, title, meta, tabs, actions, className, navLab
|
||||
}
|
||||
|
||||
function matchTab(tab, path) {
|
||||
// Button tabs (`id` + `onSelect`) carry their own `active` flag — the page
|
||||
// owns the selection (e.g. a /page/:tab URL param) rather than the route.
|
||||
if (!tab.to) return !!tab.active;
|
||||
if (tab.end) return path === tab.to;
|
||||
// Segment-aware so "/fleet" doesn't swallow "/fleet-monitor".
|
||||
return path === tab.to || path.startsWith(tab.to + '/');
|
||||
@@ -63,7 +66,8 @@ function TopbarTabs({ tabs, label }) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const safeTabs = useMemo(
|
||||
() => (Array.isArray(tabs) ? tabs : []).filter(
|
||||
(tab) => tab && typeof tab.to === 'string' && tab.to.length > 0
|
||||
(tab) => tab && ((typeof tab.to === 'string' && tab.to.length > 0)
|
||||
|| (tab.id && typeof tab.onSelect === 'function'))
|
||||
),
|
||||
[tabs]
|
||||
);
|
||||
@@ -83,14 +87,51 @@ function TopbarTabs({ tabs, label }) {
|
||||
deps: [activeIndex],
|
||||
});
|
||||
|
||||
// Arrow/Home/End roving for button tabs (WAI-ARIA tablist): move focus and
|
||||
// select, like the sibling <Tabs> primitive does.
|
||||
const onKeyDown = (e) => {
|
||||
const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
|
||||
if (!keys.includes(e.key) || !e.target.closest('[role="tab"]')) return;
|
||||
const visible = safeTabs.map((tab, i) => ({ tab, i })).filter(({ tab, i }) => !tab.to && !hiddenSet.has(i));
|
||||
const cur = visible.findIndex(({ i }) => itemRefs.current[i] === e.target);
|
||||
if (cur < 0) return;
|
||||
const rtl = getComputedStyle(e.currentTarget).direction === 'rtl';
|
||||
const step = e.key === 'ArrowRight' ? (rtl ? -1 : 1) : e.key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
|
||||
const next = e.key === 'Home' ? 0 : e.key === 'End' ? visible.length - 1 : (cur + step + visible.length) % visible.length;
|
||||
e.preventDefault();
|
||||
itemRefs.current[visible[next].i]?.focus();
|
||||
visible[next].tab.onSelect();
|
||||
};
|
||||
|
||||
const buttonTabs = safeTabs.some((tab) => !tab.to);
|
||||
|
||||
return (
|
||||
// The nav is the flex-fill measurement region (kept right-aligned so a
|
||||
// changing page title never shoves the tabs sideways); the inner bar is
|
||||
// the visible segmented control that actually holds the tabs.
|
||||
<nav ref={containerRef} className="sk-topbar__tabs" aria-label={t('app.pageTopbar.sections', '{{label}} sections', { label })}>
|
||||
<div className="sk-topbar__tabs-inner">
|
||||
<nav ref={containerRef} className="sk-topbar__tabs" onKeyDown={buttonTabs ? onKeyDown : undefined} aria-label={t('app.pageTopbar.sections', '{{label}} sections', { label })}>
|
||||
<div className="sk-topbar__tabs-inner" role={buttonTabs ? 'tablist' : undefined}>
|
||||
{safeTabs.map((tab, i) => {
|
||||
const isHidden = hiddenSet.has(i);
|
||||
if (!tab.to) {
|
||||
return (
|
||||
<SharedButton
|
||||
key={tab.id}
|
||||
variant="unstyled"
|
||||
type="button"
|
||||
role="tab"
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
aria-selected={!!tab.active}
|
||||
tabIndex={tab.active ? 0 : -1}
|
||||
className={cn('sk-topbar__tab', tab.active && 'is-active', isHidden && 'is-hidden')}
|
||||
data-overflow={isHidden ? 'hidden' : undefined}
|
||||
onClick={tab.onSelect}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</SharedButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
@@ -123,6 +164,21 @@ function TopbarTabs({ tabs, label }) {
|
||||
{hiddenIndices.map((idx) => {
|
||||
const tab = safeTabs[idx];
|
||||
if (!tab) return null;
|
||||
if (!tab.to) {
|
||||
return (
|
||||
<SharedButton
|
||||
key={tab.id}
|
||||
variant="unstyled"
|
||||
type="button"
|
||||
className="tabs-overflow-item"
|
||||
data-state={idx === activeIndex ? 'active' : 'inactive'}
|
||||
onClick={() => { setPopoverOpen(false); tab.onSelect(); }}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</SharedButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
|
||||
@@ -10,7 +10,7 @@ import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
Map as MapIcon, Layers, Compass, Network, Info,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tip } from '@/components/ui/tooltip';
|
||||
import PageLayout from '../layouts/PageLayout';
|
||||
import useTabParam from '../hooks/useTabParam';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -284,30 +284,48 @@ export default function AppMap() {
|
||||
const [showLegend, setShowLegend] = useState(true);
|
||||
const view = VIEWS.find(v => v.id === activeView) || VIEWS[0];
|
||||
|
||||
return (
|
||||
<PageLayout className="app-map" icon={<MapIcon size={18} />} title={t('app.appMap.appMap', 'App map')}>
|
||||
<Tabs value={activeView} onValueChange={setActiveView}>
|
||||
<TabsList>
|
||||
{VIEWS.map(v => (
|
||||
<TabsTrigger key={v.id} value={v.id}>
|
||||
<v.icon size={14} />
|
||||
{v.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
const viewLabels = {
|
||||
stack: t('app.appMap.requestStack', 'Request stack'),
|
||||
routes: t('app.appMap.routesApis', 'Routes and APIs'),
|
||||
topology: t('app.appMap.runtimeTopology', 'Runtime topology'),
|
||||
};
|
||||
const viewDescriptions = {
|
||||
stack: t('app.appMap.howARequestFlowsFromThe', 'How a request flows from the browser through the frontend, backend, services and database.'),
|
||||
routes: t('app.appMap.sidebarNavigationPagesBackendApiBlueprints', 'Sidebar navigation → pages → backend API blueprints they call.'),
|
||||
topology: t('app.appMap.howThePanelConnectsToServers', 'How the panel connects to servers, agents and services in production.'),
|
||||
};
|
||||
const tabs = VIEWS.map(v => ({
|
||||
id: v.id,
|
||||
label: viewLabels[v.id],
|
||||
icon: <v.icon size={15} />,
|
||||
active: v.id === activeView,
|
||||
onSelect: () => setActiveView(v.id),
|
||||
}));
|
||||
const legendLabel = showLegend ? t('app.appMap.hideLegend', 'Hide legend') : t('app.appMap.showLegend', 'Show legend');
|
||||
|
||||
<div className="app-map__viewmeta">
|
||||
<Info size={14} />
|
||||
<span>{view.description}</span>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="app-map__legend-toggle"
|
||||
onClick={() => setShowLegend(s => !s)}
|
||||
>
|
||||
{showLegend ? t('app.appMap.hideLegend', 'Hide legend') : t('app.appMap.showLegend', 'Show legend')}
|
||||
</SharedButton>
|
||||
</div>
|
||||
return (
|
||||
<PageLayout
|
||||
className="app-map"
|
||||
icon={<MapIcon size={18} />}
|
||||
title={t('app.appMap.appMap', 'App map')}
|
||||
tabs={tabs}
|
||||
navLabel={t('app.appMap.views', 'Views')}
|
||||
actions={(
|
||||
<Tip content={legendLabel}>
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
aria-label={legendLabel}
|
||||
aria-pressed={showLegend}
|
||||
onClick={() => setShowLegend(s => !s)}
|
||||
>
|
||||
<Info size={16} />
|
||||
</SharedButton>
|
||||
</Tip>
|
||||
)}
|
||||
>
|
||||
<p className="app-map__viewmeta">{viewDescriptions[view.id]}</p>
|
||||
|
||||
{showLegend && (
|
||||
<div className="app-map__legend">
|
||||
|
||||
@@ -9,31 +9,9 @@
|
||||
}
|
||||
|
||||
&__viewmeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
margin: 0 0 $space-3;
|
||||
font-size: $text-sm;
|
||||
color: $text-secondary;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid $border-subtle;
|
||||
color: $text-secondary;
|
||||
font-size: $text-xs;
|
||||
padding: $space-1 $space-3;
|
||||
border-radius: $radius-badge;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $bg-hover;
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend {
|
||||
@@ -66,7 +44,7 @@
|
||||
&__canvas {
|
||||
// ReactFlow sizes itself at height:100%, which needs a definite parent
|
||||
// height — a bare min-height collapses it to zero.
|
||||
height: max(360px, calc(100dvh - 22rem));
|
||||
height: max(360px, calc(100dvh - 16rem));
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-card;
|
||||
background: $bg-card;
|
||||
|
||||
Reference in new issue
Block a user