feat(frontend): button tabs in the page top bar; App Map views move into it

This commit is contained in:
Juan Denis committed 2026-11-03 15:04:45 -05:00
1 parent 72169e9de4
commit 3f8b6fb878
3 files changed
+103 -51

No files matched your search

+59 -3
View File
@@ -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}
+42 -24
View File
@@ -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">
+2 -24
View File
@@ -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;