-
Notifications
You must be signed in to change notification settings - Fork 25
chore: make plugin tabs scrollable #3054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,24 +1,54 @@ | ||||||||||||||||||
| import { | ||||||||||||||||||
| DropdownMenu, | ||||||||||||||||||
| DropdownMenuContent, | ||||||||||||||||||
| DropdownMenuItem, | ||||||||||||||||||
| DropdownMenuTrigger | ||||||||||||||||||
| } from "@flanksource-ui/components/ui/dropdown-menu"; | ||||||||||||||||||
| import clsx from "clsx"; | ||||||||||||||||||
| import React from "react"; | ||||||||||||||||||
| import { NavLink } from "react-router-dom"; | ||||||||||||||||||
| import { ChevronDown } from "lucide-react"; | ||||||||||||||||||
| import React, { | ||||||||||||||||||
| useCallback, | ||||||||||||||||||
| useLayoutEffect, | ||||||||||||||||||
| useRef, | ||||||||||||||||||
| useState | ||||||||||||||||||
| } from "react"; | ||||||||||||||||||
| import { NavLink, useLocation, useNavigate } from "react-router-dom"; | ||||||||||||||||||
|
|
||||||||||||||||||
| type TabLink = { | ||||||||||||||||||
| label: React.ReactNode; | ||||||||||||||||||
| path: string; | ||||||||||||||||||
| icon?: React.ReactNode; | ||||||||||||||||||
| search?: string; | ||||||||||||||||||
| key?: string; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| type RoutedTabsLinksProps = React.HTMLProps<HTMLDivElement> & { | ||||||||||||||||||
| children?: React.ReactNode; | ||||||||||||||||||
| contentClassName?: string; | ||||||||||||||||||
| containerClassName?: string; | ||||||||||||||||||
| activeTabName?: string; | ||||||||||||||||||
| tabLinks: { | ||||||||||||||||||
| label: React.ReactNode; | ||||||||||||||||||
| path: string; | ||||||||||||||||||
| icon?: React.ReactNode; | ||||||||||||||||||
| search?: string; | ||||||||||||||||||
| key?: string; | ||||||||||||||||||
| }[]; | ||||||||||||||||||
| tabLinks: TabLink[]; | ||||||||||||||||||
| // extraTabs renders custom controls (e.g. a dropdown tab) inline at the end | ||||||||||||||||||
| // of the tab row, after the routed links. | ||||||||||||||||||
| extraTabs?: React.ReactNode; | ||||||||||||||||||
| // overflowMenu keeps the tab row on a single line and collapses the tabs that | ||||||||||||||||||
| // don't fit into a trailing dropdown, preserving the original tab order. | ||||||||||||||||||
| overflowMenu?: boolean; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Space to reserve for the overflow dropdown trigger before it has been | ||||||||||||||||||
| // measured, so the first layout pass leaves room for it. | ||||||||||||||||||
| const OVERFLOW_TRIGGER_WIDTH = 80; | ||||||||||||||||||
|
|
||||||||||||||||||
| const tabClassName = (isActive: boolean, overflowMenu: boolean) => | ||||||||||||||||||
| clsx( | ||||||||||||||||||
| "mb-[-2px] cursor-pointer rounded-t-md border border-b-0 border-gray-300 px-4 py-2 text-sm font-medium hover:text-gray-900", | ||||||||||||||||||
| overflowMenu && "shrink-0 whitespace-nowrap", | ||||||||||||||||||
| isActive | ||||||||||||||||||
| ? "bg-white text-gray-900" | ||||||||||||||||||
| : "border-transparent text-gray-500" | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| export default function TabbedLinks({ | ||||||||||||||||||
| children, | ||||||||||||||||||
| className, | ||||||||||||||||||
|
|
@@ -27,24 +57,127 @@ export default function TabbedLinks({ | |||||||||||||||||
| tabLinks, | ||||||||||||||||||
| activeTabName, | ||||||||||||||||||
| extraTabs, | ||||||||||||||||||
| overflowMenu = false, | ||||||||||||||||||
| ...rest | ||||||||||||||||||
| }: RoutedTabsLinksProps) { | ||||||||||||||||||
| const navigate = useNavigate(); | ||||||||||||||||||
| const { pathname } = useLocation(); | ||||||||||||||||||
| const tabsRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||
| const tabWidthsRef = useRef<number[] | null>(null); | ||||||||||||||||||
| const triggerWidthRef = useRef(OVERFLOW_TRIGGER_WIDTH); | ||||||||||||||||||
| const [visibleCount, setVisibleCount] = useState(tabLinks.length); | ||||||||||||||||||
| const tabKeys = tabLinks.map(({ key, path }) => key ?? path).join(","); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Fit as many leading tabs as the row can show, reserving room for the | ||||||||||||||||||
| // overflow trigger when some tabs must be collapsed. Uses widths captured | ||||||||||||||||||
| // during the full-render measure pass, so it can run on every resize without | ||||||||||||||||||
| // re-rendering all the tabs. | ||||||||||||||||||
| const fitTabs = useCallback(() => { | ||||||||||||||||||
| const container = tabsRef.current; | ||||||||||||||||||
| const widths = tabWidthsRef.current; | ||||||||||||||||||
| if (!container || !widths) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| const available = container.clientWidth; | ||||||||||||||||||
| const total = widths.reduce((sum, width) => sum + width, 0); | ||||||||||||||||||
| if (total <= available) { | ||||||||||||||||||
| setVisibleCount(widths.length); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| let used = 0; | ||||||||||||||||||
| let count = 0; | ||||||||||||||||||
| for (const width of widths) { | ||||||||||||||||||
| if (used + width + triggerWidthRef.current > available) { | ||||||||||||||||||
| break; | ||||||||||||||||||
| } | ||||||||||||||||||
| used += width; | ||||||||||||||||||
| count += 1; | ||||||||||||||||||
| } | ||||||||||||||||||
| setVisibleCount(Math.max(count, 1)); | ||||||||||||||||||
| }, []); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Re-measure whenever the tab set changes: show every tab, capture their | ||||||||||||||||||
| // natural widths, then let fitTabs collapse the overflow. | ||||||||||||||||||
| useLayoutEffect(() => { | ||||||||||||||||||
| if (!overflowMenu) { | ||||||||||||||||||
| setVisibleCount(tabLinks.length); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| tabWidthsRef.current = null; | ||||||||||||||||||
| setVisibleCount(tabLinks.length); | ||||||||||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||||||||||
| }, [overflowMenu, tabKeys]); | ||||||||||||||||||
|
|
||||||||||||||||||
| useLayoutEffect(() => { | ||||||||||||||||||
| if (!overflowMenu) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| const container = tabsRef.current; | ||||||||||||||||||
| if (!container) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| if (tabWidthsRef.current === null) { | ||||||||||||||||||
| // Only measure once every tab is rendered (the full-render pass). | ||||||||||||||||||
| if (visibleCount !== tabLinks.length) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| const nodes = | ||||||||||||||||||
| container.querySelectorAll<HTMLElement>("[data-tab-link]"); | ||||||||||||||||||
| if (nodes.length !== tabLinks.length) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| tabWidthsRef.current = Array.from(nodes).map( | ||||||||||||||||||
| (node) => node.getBoundingClientRect().width | ||||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
| const trigger = | ||||||||||||||||||
| container.querySelector<HTMLElement>("[data-tab-overflow]"); | ||||||||||||||||||
| if (trigger) { | ||||||||||||||||||
| // Round up and pad so the row always reserves enough room to show the | ||||||||||||||||||
| // full "More ▾" trigger without the chevron being clipped. | ||||||||||||||||||
| triggerWidthRef.current = | ||||||||||||||||||
| Math.ceil(trigger.getBoundingClientRect().width) + 4; | ||||||||||||||||||
| } | ||||||||||||||||||
| fitTabs(); | ||||||||||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||||||||||
| }, [overflowMenu, tabKeys, visibleCount, fitTabs]); | ||||||||||||||||||
|
|
||||||||||||||||||
| useLayoutEffect(() => { | ||||||||||||||||||
| if (!overflowMenu) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| const container = tabsRef.current; | ||||||||||||||||||
| if (!container) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| const observer = new ResizeObserver(() => fitTabs()); | ||||||||||||||||||
| observer.observe(container); | ||||||||||||||||||
| return () => observer.disconnect(); | ||||||||||||||||||
| }, [overflowMenu, fitTabs]); | ||||||||||||||||||
|
|
||||||||||||||||||
| const visibleTabs = overflowMenu ? tabLinks.slice(0, visibleCount) : tabLinks; | ||||||||||||||||||
| const overflowTabs = overflowMenu ? tabLinks.slice(visibleCount) : []; | ||||||||||||||||||
| const isOverflowActive = overflowTabs.some( | ||||||||||||||||||
| ({ key, path }) => activeTabName === key || pathname === path | ||||||||||||||||||
| ); | ||||||||||||||||||
|
Comment on lines
+160
to
+162
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Guard Both Proposed fix- const isOverflowActive = overflowTabs.some(
- ({ key, path }) => activeTabName === key || pathname === path
- );
+ const isOverflowActive = overflowTabs.some(
+ ({ key, path }) =>
+ (key != null && activeTabName === key) || pathname === path
+ );
...
- tabClassName(isActive || activeTabName === key, overflowMenu)
+ tabClassName(
+ isActive || (key != null && activeTabName === key),
+ overflowMenu
+ )
...
- (activeTabName === key || pathname === path) &&
+ ((key != null && activeTabName === key) ||
+ pathname === path) &&Also applies to: 176-177, 209-212 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <div className={clsx("flex min-h-0 flex-1 flex-col", containerClassName)}> | ||||||||||||||||||
| <div | ||||||||||||||||||
| className={`flex flex-wrap border-b border-gray-300 ${className}`} | ||||||||||||||||||
| ref={tabsRef} | ||||||||||||||||||
| className={clsx( | ||||||||||||||||||
| "flex border-b border-gray-300", | ||||||||||||||||||
| overflowMenu ? "flex-nowrap overflow-hidden" : "flex-wrap", | ||||||||||||||||||
| className | ||||||||||||||||||
| )} | ||||||||||||||||||
| aria-label="Tabs" | ||||||||||||||||||
| {...rest} | ||||||||||||||||||
| > | ||||||||||||||||||
| {tabLinks.map(({ label, path, key, search, icon }) => ( | ||||||||||||||||||
| {visibleTabs.map(({ label, path, key, search, icon }) => ( | ||||||||||||||||||
| <NavLink | ||||||||||||||||||
| data-tab-link | ||||||||||||||||||
| className={({ isActive }) => | ||||||||||||||||||
| clsx( | ||||||||||||||||||
| "mb-[-2px] cursor-pointer rounded-t-md border border-b-0 border-gray-300 px-4 py-2 text-sm font-medium hover:text-gray-900", | ||||||||||||||||||
| isActive || activeTabName === key | ||||||||||||||||||
| ? "bg-white text-gray-900" | ||||||||||||||||||
| : "border-transparent text-gray-500" | ||||||||||||||||||
| ) | ||||||||||||||||||
| tabClassName(isActive || activeTabName === key, overflowMenu) | ||||||||||||||||||
| } | ||||||||||||||||||
| key={path} | ||||||||||||||||||
| to={{ | ||||||||||||||||||
|
|
@@ -58,6 +191,36 @@ export default function TabbedLinks({ | |||||||||||||||||
| </div> | ||||||||||||||||||
| </NavLink> | ||||||||||||||||||
| ))} | ||||||||||||||||||
| {overflowTabs.length > 0 && ( | ||||||||||||||||||
| <DropdownMenu> | ||||||||||||||||||
| <DropdownMenuTrigger | ||||||||||||||||||
| data-tab-overflow | ||||||||||||||||||
| aria-label="More tabs" | ||||||||||||||||||
| className={clsx( | ||||||||||||||||||
| tabClassName(isOverflowActive, overflowMenu), | ||||||||||||||||||
| "flex items-center space-x-1 focus:outline-none" | ||||||||||||||||||
| )} | ||||||||||||||||||
|
Comment on lines
+199
to
+202
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Keep a visible keyboard focus state on the overflow trigger. Line 198 removes the default focus outline without adding a replacement, making the “More” control hard to locate via keyboard navigation. Proposed fix- "flex items-center space-x-1 focus:outline-none"
+ "flex items-center space-x-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| > | ||||||||||||||||||
| <span>More</span> | ||||||||||||||||||
| <ChevronDown className="h-4 w-4 shrink-0" /> | ||||||||||||||||||
| </DropdownMenuTrigger> | ||||||||||||||||||
| <DropdownMenuContent align="end"> | ||||||||||||||||||
| {overflowTabs.map(({ label, path, key, search, icon }) => ( | ||||||||||||||||||
| <DropdownMenuItem | ||||||||||||||||||
| key={path} | ||||||||||||||||||
| onClick={() => navigate({ pathname: path, search })} | ||||||||||||||||||
| className={clsx( | ||||||||||||||||||
| "flex flex-row items-center space-x-2", | ||||||||||||||||||
| (activeTabName === key || pathname === path) && | ||||||||||||||||||
| "font-medium text-gray-900" | ||||||||||||||||||
| )} | ||||||||||||||||||
| > | ||||||||||||||||||
| {icon} <span>{label}</span> | ||||||||||||||||||
| </DropdownMenuItem> | ||||||||||||||||||
| ))} | ||||||||||||||||||
| </DropdownMenuContent> | ||||||||||||||||||
| </DropdownMenu> | ||||||||||||||||||
| )} | ||||||||||||||||||
| {extraTabs} | ||||||||||||||||||
| </div> | ||||||||||||||||||
| <div className={clsx("flex flex-col", contentClassName)}>{children}</div> | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Allow zero visible tabs when only the overflow trigger fits.
Line 96 forces one tab to remain visible even when
used + firstTabWidth + triggerWidth > available; withoverflow-hidden, the trailing “More” trigger can be clipped off and the overflowed tabs become unreachable.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents