diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index c03b24adb4bd3..e0d255d8e06c9 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -3,7 +3,6 @@ import { Tabs as TabsPrimitive } from "radix-ui"; import { type ComponentProps, createContext, - type FC, type HTMLAttributes, useCallback, useContext, @@ -18,7 +17,7 @@ import { cn } from "#/utils/cn"; type TabsProps = ComponentProps; -export const Tabs: FC = ({ ...props }) => { +export const Tabs = ({ ...props }: TabsProps) => { return ; }; @@ -39,7 +38,7 @@ const tabsListVariants = cva("flex flex-wrap items-center", { "[&_[data-slot=tabs-trigger]]:text-content-secondary [&_[data-slot=tabs-trigger][data-state=active]]:text-content-primary", "[&_[data-slot=tabs-trigger]]:border-0 [&_[data-slot=tabs-trigger]]:border-y [&_[data-slot=tabs-trigger]]:border-solid", "[&_[data-slot=tabs-trigger]]:border-transparent [&_[data-slot=tabs-trigger][data-state=active]]:border-b-white", - "[&_[data-slot=tabs-trigger]]:hover:text-content-primary", + "[&_[data-slot=tabs-trigger]:hover]:text-content-primary", "[&_[data-slot=tabs-trigger]]:px-1", ), }, @@ -53,14 +52,16 @@ type TabsListProps = ComponentProps & overflowKebabMenu?: boolean; }; -export const TabsList: FC = ({ +export const TabsList = ({ className, variant, overflowKebabMenu = false, + ref, ...props -}) => { +}: TabsListProps) => { return ( = ({ type TabsTriggerProps = ComponentProps; -export const TabsTrigger: FC = ({ +export const TabsTrigger = ({ type: triggerType = "button", ...props -}) => { +}: TabsTriggerProps) => { const type = props.asChild ? undefined : triggerType; return ( @@ -85,11 +86,12 @@ export const TabsTrigger: FC = ({ data-slot="tabs-trigger" type={type} className={cn( - "border-none py-3 bg-transparent", + "border-none py-2.5 bg-transparent", "text-inherit font-normal text-sm", "inline-flex gap-2 items-center", "cursor-pointer", "transition-colors duration-150 ease-linear", + "-mb-px", )} {...props} /> @@ -98,7 +100,7 @@ export const TabsTrigger: FC = ({ type TabsContentProps = ComponentProps; -export const TabsContent: FC = ({ ...props }) => { +export const TabsContent = ({ ...props }: TabsContentProps) => { return ; }; @@ -117,11 +119,11 @@ const LinkTabsContext = createContext( type LinkTabsProps = HTMLAttributes & LinkTabsContextValue; -export const LinkTabs: FC = ({ +export const LinkTabs = ({ className, active, ...htmlProps -}) => { +}: LinkTabsProps) => { return (
= ({ type LinkTabsListProps = HTMLAttributes; -export const LinkTabsList: FC = ({ - className, - ...props -}) => { +export const LinkTabsList = ({ className, ...props }: LinkTabsListProps) => { const tabsContext = useContext(LinkTabsContext); const listRef = useRef(null); const indicatorRef = useRef(null); @@ -217,11 +216,7 @@ type TabLinkProps = LinkProps & { value: string; }; -export const TabLink: FC = ({ - value, - className, - ...linkProps -}) => { +export const TabLink = ({ value, className, ...linkProps }: TabLinkProps) => { const tabsContext = useContext(LinkTabsContext); if (!tabsContext) { throw new Error("TabLink must be used inside LinkTabs"); diff --git a/site/src/components/Tabs/utils/useKebabMenu.test.tsx b/site/src/components/Tabs/utils/useKebabMenu.test.tsx new file mode 100644 index 0000000000000..60cb23a48f586 --- /dev/null +++ b/site/src/components/Tabs/utils/useKebabMenu.test.tsx @@ -0,0 +1,134 @@ +import { act, render, screen } from "@testing-library/react"; +import { useKebabMenu } from "./useKebabMenu"; + +type FakeResizeObserverInstance = { + simulateResize: (width: number) => void; +}; + +let resizeObserverInstances: FakeResizeObserverInstance[] = []; + +class MockResizeObserver { + private readonly callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + const self = this; + resizeObserverInstances.push({ + simulateResize(width: number) { + self.callback( + [{ contentRect: { width, height: 0 } } as ResizeObserverEntry], + self as unknown as ResizeObserver, + ); + }, + }); + } + + observe(_target: Element) {} + unobserve(_target: Element) {} + disconnect() {} +} + +const getLastResizeObserver = (): FakeResizeObserverInstance => { + const instance = resizeObserverInstances[resizeObserverInstances.length - 1]; + if (!instance) { + throw new Error("No ResizeObserver was constructed"); + } + return instance; +}; + +const setElementOffsetWidth = (element: HTMLElement, width: number): void => { + Object.defineProperty(element, "offsetWidth", { + configurable: true, + get: () => width, + }); +}; + +const tabs = [ + { value: "all", label: "All Logs" }, + { value: "build", label: "Build Logs" }, + { value: "startup", label: "Startup Script" }, +] as const; + +const TestHarness = ({ tabGap = 0 }: { tabGap?: number }) => { + const { containerRef, visibleTabs, overflowTabs, getTabMeasureProps } = + useKebabMenu({ + tabs, + enabled: true, + isActive: true, + overflowTriggerWidth: 44, + }); + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ {visibleTabs.map((tab) => tab.value).join(",")} +
+
+ {overflowTabs.map((tab) => tab.value).join(",")} +
+
+ ); +}; + +describe("useKebabMenu", () => { + beforeEach(() => { + resizeObserverInstances = []; + vi.stubGlobal("ResizeObserver", MockResizeObserver); + }); + + afterEach(() => { + // Keep tests isolated when other suites spy on globals. + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("shows all tabs when the available width is enough", async () => { + render(); + + const [all, build, startup] = screen.getAllByRole("button"); + setElementOffsetWidth(all, 60); + setElementOffsetWidth(build, 70); + setElementOffsetWidth(startup, 70); + + await act(() => { + getLastResizeObserver().simulateResize(220); + }); + + expect(screen.getByTestId("visible-values")).toHaveTextContent( + "all,build,startup", + ); + expect(screen.getByTestId("overflow-values")).toBeEmptyDOMElement(); + }); + + it("accounts for outsideBox tab gap when reserving kebab space", async () => { + render(); + + const [all, build, startup] = screen.getAllByRole("button"); + setElementOffsetWidth(all, 60); + setElementOffsetWidth(build, 70); + setElementOffsetWidth(startup, 70); + + await act(() => { + getLastResizeObserver().simulateResize(220); + }); + + expect(screen.getByTestId("visible-values")).toHaveTextContent("all"); + expect(screen.getByTestId("overflow-values")).toHaveTextContent( + "build,startup", + ); + }); +}); diff --git a/site/src/components/Tabs/utils/useKebabMenu.ts b/site/src/components/Tabs/utils/useKebabMenu.ts index aa6336d47dc27..b3afb9146bb6a 100644 --- a/site/src/components/Tabs/utils/useKebabMenu.ts +++ b/site/src/components/Tabs/utils/useKebabMenu.ts @@ -1,7 +1,6 @@ import { type RefObject, useCallback, - useEffect, useLayoutEffect, useRef, useState, @@ -41,10 +40,6 @@ export const useKebabMenu = ({ overflowTriggerWidth = 44, }: UseKebabMenuOptions): UseKebabMenuResult => { const containerRef = useRef(null); - const tabsRef = useRef(tabs); - tabsRef.current = tabs; - const previousTabsRef = useRef(tabs); - const availableWidthRef = useRef(null); // Width cache prevents oscillation when overflow tabs are not mounted. const tabWidthByValueRef = useRef>({}); const [overflowTabValues, setTabValues] = useState([]); @@ -66,20 +61,20 @@ export const useKebabMenu = ({ if (!container) { return; } - const currentTabs = tabsRef.current; - const tabWidthByValue = measureTabWidths({ - tabs: currentTabs, + tabs, container, previousTabWidthByValue: tabWidthByValueRef.current, }); tabWidthByValueRef.current = tabWidthByValue; + const tabGap = getTabGap(container); const nextOverflowValues = calculateTabValues({ - tabs: currentTabs, + tabs, availableWidth, tabWidthByValue, overflowTriggerWidth, + tabGap, }); setTabValues((currentValues) => { @@ -90,35 +85,34 @@ export const useKebabMenu = ({ return nextOverflowValues; }); }, - [enabled, isActive, overflowTriggerWidth], + [enabled, isActive, overflowTriggerWidth, tabs], ); - useEffect(() => { - if (previousTabsRef.current === tabs) { - // No change in tabs, no need to recalculate. + useLayoutEffect(() => { + const container = containerRef.current; + if (!enabled || !isActive) { + // Keep this update idempotent to avoid render loops. + setTabValues((currentValues) => { + if (currentValues.length === 0) { + return currentValues; + } + return []; + }); return; } - previousTabsRef.current = tabs; - if (availableWidthRef.current === null) { - // First mount, no width available yet. + if (!container) { return; } - recalculateOverflow(availableWidthRef.current); - }, [recalculateOverflow, tabs]); - useLayoutEffect(() => { - const container = containerRef.current; - if (!container || !enabled || !isActive) { - return; - } + recalculateOverflow(getContentBoxWidth(container)); // Recompute whenever ResizeObserver reports a container width change. const observer = new ResizeObserver(([entry]) => { if (!entry) { return; } - availableWidthRef.current = entry.contentRect.width; - recalculateOverflow(entry.contentRect.width); + const nextAvailableWidth = Math.max(0, entry.contentRect.width); + recalculateOverflow(nextAvailableWidth); }); observer.observe(container); return () => observer.disconnect(); @@ -157,47 +151,40 @@ const calculateTabValues = ({ availableWidth, tabWidthByValue, overflowTriggerWidth, + tabGap, }: { tabs: readonly T[]; availableWidth: number; tabWidthByValue: Readonly>; overflowTriggerWidth: number; + tabGap: number; }): string[] => { - const tabWidthByValueMap = new Map(); - for (const tab of tabs) { - tabWidthByValueMap.set(tab.value, tabWidthByValue[tab.value] ?? 0); - } - - const firstOptionalTabIndex = Math.min( - ALWAYS_VISIBLE_TABS_COUNT, - tabs.length, - ); - if (firstOptionalTabIndex >= tabs.length) { + if (tabs.length <= ALWAYS_VISIBLE_TABS_COUNT) { return []; } - const alwaysVisibleTabs = tabs.slice(0, firstOptionalTabIndex); - const optionalTabs = tabs.slice(firstOptionalTabIndex); - const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => { - return total + (tabWidthByValueMap.get(tab.value) ?? 0); - }, 0); - const firstTabIndex = findFirstTabIndex({ - optionalTabs, - optionalTabWidths: optionalTabs.map((tab) => { - return tabWidthByValueMap.get(tab.value) ?? 0; - }), - startingUsedWidth: alwaysVisibleWidth, - availableWidth, - overflowTriggerWidth, - }); + let usedWidth = 0; + let visibleCount = 0; + + for (const [index, tab] of tabs.entries()) { + const tabWidth = tabWidthByValue[tab.value] ?? 0; + const gapBeforeTab = visibleCount > 0 ? tabGap : 0; + const usedWidthWithTab = usedWidth + gapBeforeTab + tabWidth; + const hasMoreTabs = index < tabs.length - 1; + // Reserve kebab trigger width whenever additional tabs remain. + const widthNeeded = + usedWidthWithTab + (hasMoreTabs ? tabGap + overflowTriggerWidth : 0); + + if (index < ALWAYS_VISIBLE_TABS_COUNT || widthNeeded <= availableWidth) { + usedWidth = usedWidthWithTab; + visibleCount += 1; + continue; + } - if (firstTabIndex === -1) { - return []; + return tabs.slice(index).map((overflowTab) => overflowTab.value); } - return optionalTabs - .slice(firstTabIndex) - .map((overflowTab) => overflowTab.value); + return []; }; const measureTabWidths = ({ @@ -221,47 +208,17 @@ const measureTabWidths = ({ return nextTabWidthByValue; }; -const findFirstTabIndex = ({ - optionalTabs, - optionalTabWidths, - startingUsedWidth, - availableWidth, - overflowTriggerWidth, -}: { - optionalTabs: readonly TabValue[]; - optionalTabWidths: readonly number[]; - startingUsedWidth: number; - availableWidth: number; - overflowTriggerWidth: number; -}): number => { - const result = optionalTabs.reduce( - (acc, _tab, index) => { - if (acc.firstTabIndex !== -1) { - return acc; - } - - const tabWidth = optionalTabWidths[index] ?? 0; - const hasMoreTabs = index < optionalTabs.length - 1; - // Reserve kebab trigger width whenever additional tabs remain. - const widthNeeded = - acc.usedWidth + tabWidth + (hasMoreTabs ? overflowTriggerWidth : 0); - - if (widthNeeded <= availableWidth) { - return { - usedWidth: acc.usedWidth + tabWidth, - firstTabIndex: -1, - }; - } - - return { - usedWidth: acc.usedWidth, - firstTabIndex: index, - }; - }, - { usedWidth: startingUsedWidth, firstTabIndex: -1 }, - ); +const getContentBoxWidth = (container: HTMLElement): number => { + const styles = window.getComputedStyle(container); + const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0; + const paddingRight = Number.parseFloat(styles.paddingRight) || 0; + return container.clientWidth - paddingLeft - paddingRight; +}; - return result.firstTabIndex; +const getTabGap = (container: HTMLElement): number => { + const styles = window.getComputedStyle(container); + const gap = Number.parseFloat(styles.columnGap); + return Number.isFinite(gap) ? gap : 0; }; const areStringArraysEqual = ( diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index eed5b58dc2005..ccf0f3761df61 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -2,6 +2,7 @@ import Collapse from "@mui/material/Collapse"; import { CopyIcon, EllipsisIcon, + PackageIcon, PlayIcon, SquareCheckBigIcon, TriangleAlertIcon, @@ -280,6 +281,7 @@ export const AgentRow: FC = ({ { title: "All Logs", value: "all", + startIcon: , }, ...(startupScriptLogTab ? [startupScriptLogTab] : []), ...sortedSourceLogTabs, @@ -535,11 +537,13 @@ export const AgentRow: FC = ({ onValueChange={setSelectedLogTab} >
-
- +
+ {visibleLogTabs.map((tab) => ( = ({ : "inactive" } aria-label="More log tabs" - className="border-none py-4 bg-transparent text-inherit inline-flex items-center justify-center cursor-pointer transition-colors duration-150 ease-linear" + className={cn( + "cursor-pointer -mb-px", + "inline-flex items-center justify-center", + "border-none py-3 bg-transparent text-inherit", + "transition-colors duration-150 ease-linear", + )} > More log tabs