diff --git a/frontend/src/components/ds/PageTopbar.jsx b/frontend/src/components/ds/PageTopbar.jsx index c0db28c4..13d562a2 100644 --- a/frontend/src/components/ds/PageTopbar.jsx +++ b/frontend/src/components/ds/PageTopbar.jsx @@ -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 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. -