mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(ui): tab component (#9673)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
parent
553f0a0918
commit
387318385c
90
openhands-ui/components/tabs/Tabs.stories.tsx
Normal file
90
openhands-ui/components/tabs/Tabs.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
102
openhands-ui/components/tabs/Tabs.tsx
Normal file
102
openhands-ui/components/tabs/Tabs.tsx
Normal 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 };
|
||||
58
openhands-ui/components/tabs/components/TabItem.tsx
Normal file
58
openhands-ui/components/tabs/components/TabItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
openhands-ui/components/tabs/components/TabScroller.tsx
Normal file
43
openhands-ui/components/tabs/components/TabScroller.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
openhands-ui/components/tabs/hooks/use-element-overflow.tsx
Normal file
37
openhands-ui/components/tabs/hooks/use-element-overflow.tsx
Normal 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;
|
||||
}
|
||||
69
openhands-ui/components/tabs/hooks/use-element-scroll.tsx
Normal file
69
openhands-ui/components/tabs/hooks/use-element-scroll.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user