feat(ui): tab component (#9673)

Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Mislav Lukach 2025-07-16 18:38:51 +02:00 committed by GitHub
parent 553f0a0918
commit 387318385c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 400 additions and 0 deletions

View File

@ -0,0 +1,90 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Tabs } from "./Tabs";
const meta = {
title: "Components/Tabs",
component: Tabs,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Main: Story = {
args: {
children: null,
},
render: ({}) => (
<Tabs>
<Tabs.Item text="Overview" icon="HouseFill">
Summary of data
</Tabs.Item>
<Tabs.Item text="Analytics" icon="BarChartFill">
Traffic and metrics
</Tabs.Item>
<Tabs.Item text="Settings" icon="GearFill">
Customize profile
</Tabs.Item>
</Tabs>
),
};
export const Scrollable: Story = {
args: {
children: null,
},
render: ({}) => (
<div className="max-w-md">
<Tabs>
<Tabs.Item text="Overview" icon="HouseFill">
Summary of data
</Tabs.Item>
<Tabs.Item text="Analytics" icon="BarChartFill">
Traffic and metrics
</Tabs.Item>
<Tabs.Item text="Settings" icon="GearFill">
Customize profile
</Tabs.Item>
<Tabs.Item text="Billing" icon="CreditCardFill">
Manage invoices
</Tabs.Item>
<Tabs.Item text="Integrations" icon="PlugFill">
Third-party services
</Tabs.Item>
<Tabs.Item text="Notifications" icon="BellFill">
Set alert preferences
</Tabs.Item>
<Tabs.Item text="Reports" icon="FileEarmarkBarGraphFill">
Generate PDF reports
</Tabs.Item>
<Tabs.Item text="Feedback" icon="ChatDotsFill">
Leave your thoughts
</Tabs.Item>
<Tabs.Item text="Access Control" icon="ShieldLockFill">
Manage roles and permissions
</Tabs.Item>
<Tabs.Item text="Activity Log" icon="ClockHistory">
Track recent actions
</Tabs.Item>
<Tabs.Item text="Support" icon="LifePreserver">
Get help and resources
</Tabs.Item>
<Tabs.Item text="API Keys" icon="KeyFill">
Generate or revoke keys
</Tabs.Item>
<Tabs.Item text="Localization" icon="Translate">
Language and region
</Tabs.Item>
<Tabs.Item text="Deployments" icon="CloudUploadFill">
View deployment history
</Tabs.Item>
<Tabs.Item text="Audit Trail" icon="FileLockFill">
Security and compliance logs
</Tabs.Item>
</Tabs>
</div>
),
};

View File

@ -0,0 +1,102 @@
import {
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import type { HTMLProps } from "../../shared/types";
import { cn } from "../../shared/utils/cn";
import React from "react";
import {
TabItem,
type TabItemProps,
type TabItemPropsPublic,
} from "./components/TabItem";
import { useElementOverflow } from "./hooks/use-element-overflow";
import { useElementScroll } from "./hooks/use-element-scroll";
import { TabScroller } from "./components/TabScroller";
export type TabsProps = HTMLProps<"div">;
type TabsType = React.FC<PropsWithChildren<TabsProps>> & {
Item: React.FC<PropsWithChildren<TabItemPropsPublic>>;
};
const Tabs: TabsType = ({ children, ...props }) => {
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const tabListRef = useRef<HTMLDivElement>(null);
const isOverflowing = useElementOverflow({
contentRef: tabListRef,
containerRef,
});
const { canScrollLeft, canScrollRight, scrollLeft, scrollRight } =
useElementScroll({
containerRef,
scrollRef: tabListRef,
});
const childrenArray = React.Children.toArray(children);
const tabs =
React.Children.map(children, (child, index: number) => {
const isFirst = index === 0;
const isLast = childrenArray.length - 1 === index;
return React.cloneElement(
child as ReactElement,
{
index,
isFirst,
isLast,
isActive: index === activeIndex,
onSelect: () => setActiveIndex(index),
} as TabItemProps
);
}) ?? [];
return (
<div className={cn("w-full")}>
<div className={cn("flex flex-row items-stretch")} ref={containerRef}>
{canScrollLeft && isOverflowing && (
<TabScroller onScroll={scrollLeft} position="left" />
)}
<div
className={cn("flex", "overflow-x-auto scrollbar-none")}
ref={tabListRef}
role="tablist"
aria-label="Tabs"
>
{tabs}
</div>
{canScrollRight && isOverflowing && (
<TabScroller onScroll={scrollRight} position="right" />
)}
</div>
<div
className={cn(
"border-1 border-t-0 rounded-b-2xl border-light-neutral-500",
"bg-grey-970 p-4 text-light-neutral-300"
)}
>
{tabs.map((child, index) => {
if (index !== activeIndex) {
return null;
}
const tabContent = (child.props as PropsWithChildren).children;
return (
<div key={index} role="tabpanel" aria-labelledby={`tab-${index}`}>
{tabContent}
</div>
);
})}
</div>
</div>
);
};
Tabs.Item = TabItem as React.FC<PropsWithChildren<TabItemPropsPublic>>;
export { Tabs };

View File

@ -0,0 +1,58 @@
import type { HTMLProps } from "../../../shared/types";
import { cn } from "../../../shared/utils/cn";
import React from "react";
import { Icon, type IconProps } from "../../icon/Icon";
import { Typography } from "../../typography/Typography";
export type TabItemProps = HTMLProps<"div"> & {
icon?: IconProps["icon"];
text: string;
children: React.ReactNode;
index: number;
isActive: boolean;
isFirst: boolean;
isLast: boolean;
onSelect: () => void;
};
export type TabItemPropsPublic = Omit<
TabItemProps,
"index" | "isActive" | "isFirst" | "isLast" | "onSelect"
>;
export const TabItem = ({
text,
index,
isActive,
isFirst,
isLast,
onSelect,
icon,
}: TabItemProps) => {
return (
<button
role="tab"
id={`tab-${index}`}
aria-selected={isActive}
aria-controls={`panel-${index}`}
className={cn(
"flex items-center gap-x-3 cursor-pointer",
"px-6 py-3",
"text-light-neutral-15 whitespace-nowrap",
"border-light-neutral-500 border-b-1 border-t-1 border-r-1",
"bg-light-neutral-970 focus:outline-0",
"enabled:hover:bg-light-neutral-500",
"enabled:focus:bg-grey-970",
"enabled:active:bg-grey-970 enabled:active:text-primary-500",
isActive && "enabled:text-primary-500",
isFirst && "border-l-1 rounded-tl-2xl",
isLast && "rounded-tr-2xl"
)}
onClick={onSelect}
>
{icon && <Icon icon={icon} className={cn("w-4 h-4 shrink-0")} />}
<Typography.Text fontSize={"s"} fontWeight={400}>
{text}
</Typography.Text>
</button>
);
};

View File

@ -0,0 +1,43 @@
import { cn } from "../../../shared/utils/cn";
import { Icon, type IconProps } from "../../icon/Icon";
type TabScrollerProps = {
position: "left" | "right";
onScroll(): void;
};
const tabScrollMetadata: Record<
TabScrollerProps["position"],
{ className: string; icon: IconProps["icon"] }
> = {
left: {
className: cn("rounded-tl-2xl"),
icon: "ChevronDoubleLeft",
},
right: {
className: cn("rounded-tr-2xl"),
icon: "ChevronDoubleRight",
},
};
export const TabScroller = ({ position, onScroll }: TabScrollerProps) => {
const { className, icon } = tabScrollMetadata[position];
return (
<button
onClick={onScroll}
className={cn(
"border-1 border-light-neutral-500",
"flex flex-row items-center px-4",
"bg-light-neutral-970",
"enabled:hover:bg-light-neutral-500",
"enabled:focus:bg-light-neutral-970",
"enabled:active:bg-grey-970",
className
)}
aria-label={`Scroll tabs ${position}`}
>
<Icon icon={icon} className={cn("w-4.5 h-4.5 text-primary-500")} />
</button>
);
};

View File

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import React from "react";
type UseElementOverflowParams = {
contentRef: React.RefObject<HTMLDivElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
};
export function useElementOverflow({
contentRef,
containerRef,
}: UseElementOverflowParams): boolean {
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const checkOverflow = () => {
const container = containerRef.current;
const content = contentRef.current;
if (container && content) {
setIsOverflowing(content.scrollWidth > container.clientWidth);
}
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
if (contentRef.current) {
resizeObserver.observe(contentRef.current);
}
return () => resizeObserver.disconnect();
}, [containerRef, contentRef]);
return isOverflowing;
}

View File

@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from "react";
import React from "react";
const SCROLL_OFFSET = 200;
type UseElementScrollParams = {
containerRef: React.RefObject<HTMLDivElement | null>;
scrollRef: React.RefObject<HTMLDivElement | null>;
};
export function useElementScroll({
containerRef,
scrollRef,
}: UseElementScrollParams) {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const container = containerRef?.current;
const scroll = scrollRef?.current;
if (!container || !scroll) {
return;
}
const maxScrollLeft = scroll.scrollWidth - container.clientWidth;
setCanScrollLeft(scroll.scrollLeft > 0);
setCanScrollRight(scroll.scrollLeft < maxScrollLeft);
}, [containerRef, scrollRef]);
useEffect(() => {
const scroll = scrollRef?.current;
if (!scroll) {
return;
}
updateScrollState();
const preventScroll = (e: Event) => {
e.preventDefault();
};
scroll.addEventListener("wheel", preventScroll, { passive: false });
scroll.addEventListener("scroll", updateScrollState);
window.addEventListener("resize", updateScrollState);
return () => {
scroll.removeEventListener("wheel", preventScroll);
scroll.removeEventListener("scroll", updateScrollState);
window.removeEventListener("resize", updateScrollState);
};
}, [updateScrollState]);
const scrollLeft = useCallback(() => {
scrollRef?.current?.scrollBy?.({
left: -SCROLL_OFFSET,
behavior: "smooth",
});
}, []);
const scrollRight = useCallback(() => {
scrollRef?.current?.scrollBy?.({ left: SCROLL_OFFSET, behavior: "smooth" });
}, []);
return {
canScrollLeft,
canScrollRight,
scrollLeft,
scrollRight,
};
}

View File

@ -12,6 +12,7 @@ export { Scrollable } from "./components/scrollable/Scrollable";
export { ToastManager } from "./components/toast/ToastManager";
export { toasterMessages } from "./components/toast/Toast";
export { Toggle } from "./components/toggle/Toggle";
export { Tabs } from "./components/tabs/Tabs";
export { Tooltip } from "./components/tooltip/Tooltip";
export { Typography } from "./components/typography/Typography";