Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions site/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Tabs as TabsPrimitive } from "radix-ui";
import {
type ComponentProps,
createContext,
type FC,
type HTMLAttributes,
useCallback,
useContext,
Expand All @@ -18,7 +17,7 @@ import { cn } from "#/utils/cn";

type TabsProps = ComponentProps<typeof TabsPrimitive.Root>;

export const Tabs: FC<TabsProps> = ({ ...props }) => {
export const Tabs = ({ ...props }: TabsProps) => {
return <TabsPrimitive.Root data-slot="tabs" {...props} />;
};

Expand All @@ -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",
),
},
Expand All @@ -53,14 +52,16 @@ type TabsListProps = ComponentProps<typeof TabsPrimitive.List> &
overflowKebabMenu?: boolean;
};

export const TabsList: FC<TabsListProps> = ({
export const TabsList = ({
className,
variant,
overflowKebabMenu = false,
ref,
...props
}) => {
}: TabsListProps) => {
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
className={cn(
tabsListVariants({ variant }),
Expand All @@ -74,22 +75,23 @@ export const TabsList: FC<TabsListProps> = ({

type TabsTriggerProps = ComponentProps<typeof TabsPrimitive.Trigger>;

export const TabsTrigger: FC<TabsTriggerProps> = ({
export const TabsTrigger = ({
type: triggerType = "button",
...props
}) => {
}: TabsTriggerProps) => {
const type = props.asChild ? undefined : triggerType;

return (
<TabsPrimitive.Trigger
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}
/>
Expand All @@ -98,7 +100,7 @@ export const TabsTrigger: FC<TabsTriggerProps> = ({

type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>;

export const TabsContent: FC<TabsContentProps> = ({ ...props }) => {
export const TabsContent = ({ ...props }: TabsContentProps) => {
return <TabsPrimitive.Content data-slot="tabs-content" {...props} />;
};

Expand All @@ -117,11 +119,11 @@ const LinkTabsContext = createContext<LinkTabsContextValue | undefined>(

type LinkTabsProps = HTMLAttributes<HTMLDivElement> & LinkTabsContextValue;

export const LinkTabs: FC<LinkTabsProps> = ({
export const LinkTabs = ({
className,
active,
...htmlProps
}) => {
}: LinkTabsProps) => {
return (
<LinkTabsContext.Provider value={{ active }}>
<div
Expand All @@ -140,10 +142,7 @@ export const LinkTabs: FC<LinkTabsProps> = ({

type LinkTabsListProps = HTMLAttributes<HTMLDivElement>;

export const LinkTabsList: FC<LinkTabsListProps> = ({
className,
...props
}) => {
export const LinkTabsList = ({ className, ...props }: LinkTabsListProps) => {
const tabsContext = useContext(LinkTabsContext);
const listRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -217,11 +216,7 @@ type TabLinkProps = LinkProps & {
value: string;
};

export const TabLink: FC<TabLinkProps> = ({
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");
Expand Down
134 changes: 134 additions & 0 deletions site/src/components/Tabs/utils/useKebabMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div
ref={containerRef}
style={{ display: "flex", columnGap: `${tabGap}px` }}
>
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
{...getTabMeasureProps(tab.value)}
>
{tab.label}
</button>
))}
</div>
<div data-testid="visible-values">
{visibleTabs.map((tab) => tab.value).join(",")}
</div>
<div data-testid="overflow-values">
{overflowTabs.map((tab) => tab.value).join(",")}
</div>
</div>
);
};

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(<TestHarness />);

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(<TestHarness tabGap={24} />);

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",
);
});
});
Loading
Loading