diff --git a/.changeset/curly-snails-yell.md b/.changeset/curly-snails-yell.md
new file mode 100644
index 0000000000..20123fb115
--- /dev/null
+++ b/.changeset/curly-snails-yell.md
@@ -0,0 +1,5 @@
+---
+"@heroui/tabs": patch
+---
+
+Added ellipsis to tabs (#3573)
diff --git a/apps/docs/content/components/tabs/index.ts b/apps/docs/content/components/tabs/index.ts
index 62c77733c7..c77931f9bb 100644
--- a/apps/docs/content/components/tabs/index.ts
+++ b/apps/docs/content/components/tabs/index.ts
@@ -12,6 +12,7 @@ import controlled from "./controlled";
import customStyles from "./custom-styles";
import placement from "./placement";
import vertical from "./vertical";
+import overflow from "./overflow";
export const tabsContent = {
usage,
@@ -28,4 +29,5 @@ export const tabsContent = {
customStyles,
placement,
vertical,
+ overflow,
};
diff --git a/apps/docs/content/components/tabs/overflow.raw.jsx b/apps/docs/content/components/tabs/overflow.raw.jsx
new file mode 100644
index 0000000000..2773c65bb5
--- /dev/null
+++ b/apps/docs/content/components/tabs/overflow.raw.jsx
@@ -0,0 +1,17 @@
+import {Tabs, Tab, Card, CardBody} from "@heroui/react";
+
+export default function App() {
+ return (
+
+
+ {Array.from({length: 20}, (_, i) => (
+
+
+ Content for tab {i + 1}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/docs/content/components/tabs/overflow.ts b/apps/docs/content/components/tabs/overflow.ts
new file mode 100644
index 0000000000..415861b297
--- /dev/null
+++ b/apps/docs/content/components/tabs/overflow.ts
@@ -0,0 +1,9 @@
+import App from "./overflow.raw.jsx?raw";
+
+const react = {
+ "/App.jsx": App,
+};
+
+export default {
+ ...react,
+};
diff --git a/apps/docs/content/docs/components/tabs.mdx b/apps/docs/content/docs/components/tabs.mdx
index c88f998b56..2c2870734a 100644
--- a/apps/docs/content/docs/components/tabs.mdx
+++ b/apps/docs/content/docs/components/tabs.mdx
@@ -98,6 +98,29 @@ Change the orientation of the tabs it will invalidate the placement prop when th
+### Overflow Behavior
+
+When there are too many tabs to fit in the container, a "show more" button appears:
+
+- Hidden tabs are accessible through a dropdown menu
+- The button can be customized with a different icon or styles
+
+
+
+You can customize the overflow button appearance:
+
+```jsx
+
+ // ... tabs content
+
+```
+
### Links
Tabs items can be rendered as links by passing the `href` prop to the `Tab` component. By
@@ -200,6 +223,8 @@ function AppTabs() {
- **cursor**: The cursor slot, it wraps the cursor. This is only visible when `disableAnimation=false`
- **panel**: The panel slot, it wraps the tab panel (content).
- **tabWrapper**: The tab wrapper slot, it wraps the tab and the tab content.
+- **moreButton**: The "show more" button that appears when tabs overflow
+- **moreIcon**: The icon inside the "show more" button
### Custom Styles
@@ -344,7 +369,7 @@ You can customize the `Tabs` component by passing custom Tailwind CSS classes to
},
{
attribute: "classNames",
- type: "Partial>",
+ type: "Partial>",
description: "Allows to set custom class names for the card slots.",
default: "-"
},
diff --git a/packages/components/tabs/__tests__/tabs.test.tsx b/packages/components/tabs/__tests__/tabs.test.tsx
index 338208dc8b..8d121b7bf4 100644
--- a/packages/components/tabs/__tests__/tabs.test.tsx
+++ b/packages/components/tabs/__tests__/tabs.test.tsx
@@ -400,4 +400,105 @@ describe("Tabs", () => {
);
expect(ref.current).not.toBeNull();
});
+
+ const mockTabListDimensions = (el: HTMLElement | null, overflowing: boolean) => {
+ if (!el) return;
+
+ Object.defineProperty(el, "scrollWidth", {
+ configurable: true,
+ value: overflowing ? 500 : 200,
+ });
+ Object.defineProperty(el, "clientWidth", {
+ configurable: true,
+ value: 200,
+ });
+ };
+
+ const mockTabPositions = (container: HTMLElement | null) => {
+ const CONTAINER_WIDTH = 200;
+ const TAB_WIDTH = 100;
+ const VISIBLE_TABS = 2;
+
+ if (!container) return;
+
+ // Mock getBoundingClientRect for container
+ const containerRect = {left: 0, right: CONTAINER_WIDTH};
+
+ const containerSpy = jest
+ .spyOn(container, "getBoundingClientRect")
+ .mockImplementation(() => containerRect as DOMRect);
+
+ // Mock tab elements positions
+ const tabs = container.querySelectorAll("[data-key]");
+ const spies: jest.SpyInstance[] = [];
+
+ tabs.forEach((tab, index) => {
+ if (!(tab instanceof HTMLElement)) return;
+
+ const isHidden = index >= VISIBLE_TABS;
+ const left = isHidden ? CONTAINER_WIDTH + TAB_WIDTH : index * TAB_WIDTH;
+ const right = left + TAB_WIDTH;
+
+ spies.push(
+ jest
+ .spyOn(tab, "getBoundingClientRect")
+ .mockImplementation(() => ({left, right} as DOMRect)),
+ );
+ });
+
+ return () => {
+ containerSpy.mockRestore();
+ spies.forEach((spy) => spy.mockRestore());
+ };
+ };
+
+ it("should show overflow menu when tabs overflow", () => {
+ const {container, getByLabelText} = render(
+
+
+ Content 1
+
+
+ Content 2
+
+
+ Content 3
+
+
+ Content 4
+
+ ,
+ );
+
+ const tabList = container.querySelector('[role="tablist"]') as HTMLElement;
+
+ mockTabListDimensions(tabList, true);
+ mockTabPositions(tabList);
+
+ fireEvent.scroll(tabList);
+
+ expect(getByLabelText("Show more tabs")).toBeInTheDocument();
+ });
+
+ it("should not show overflow menu when tabs don't overflow", () => {
+ const {container, queryByLabelText} = render(
+
+
+ Content 1
+
+
+ Content 2
+
+ ,
+ );
+
+ const tabList = container.querySelector('[role="tablist"]') as HTMLElement;
+
+ mockTabListDimensions(tabList, false);
+ mockTabPositions(tabList);
+
+ fireEvent.scroll(tabList);
+
+ expect(queryByLabelText("Show more tabs")).not.toBeInTheDocument();
+ });
});
diff --git a/packages/components/tabs/package.json b/packages/components/tabs/package.json
index dd0effe05d..fad6d1e7d1 100644
--- a/packages/components/tabs/package.json
+++ b/packages/components/tabs/package.json
@@ -63,6 +63,7 @@
"react-lorem-component": "0.13.0",
"@heroui/card": "workspace:*",
"@heroui/input": "workspace:*",
+ "@heroui/dropdown": "workspace:*",
"@heroui/test-utils": "workspace:*",
"@heroui/button": "workspace:*",
"@heroui/shared-icons": "workspace:*",
diff --git a/packages/components/tabs/src/tabs.tsx b/packages/components/tabs/src/tabs.tsx
index d6c1771a45..a5ac662994 100644
--- a/packages/components/tabs/src/tabs.tsx
+++ b/packages/components/tabs/src/tabs.tsx
@@ -1,6 +1,11 @@
-import {ForwardedRef, ReactElement, useId} from "react";
+import {ForwardedRef, ReactElement, useId, useState, useEffect, useCallback} from "react";
import {LayoutGroup} from "framer-motion";
+import {Button} from "@heroui/button";
import {forwardRef} from "@heroui/system";
+import {EllipsisIcon} from "@heroui/shared-icons";
+import {debounce} from "@heroui/shared-utils";
+import {Dropdown, DropdownTrigger, DropdownMenu, DropdownItem} from "@heroui/dropdown";
+import {clsx} from "@heroui/shared-utils";
import {UseTabsProps, useTabs} from "./use-tabs";
import Tab from "./tab";
@@ -28,8 +33,106 @@ const Tabs = forwardRef(function Tabs(
});
const layoutId = useId();
+ const [showOverflow, setShowOverflow] = useState(false);
+ const [hiddenTabs, setHiddenTabs] = useState>([]);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const layoutGroupEnabled = !props.disableAnimation && !props.disableCursorAnimation;
+ const tabListProps = getTabListProps();
+ const tabList =
+ tabListProps.ref && "current" in tabListProps.ref ? tabListProps.ref.current : null;
+
+ const checkOverflow = useCallback(() => {
+ if (!tabList) return;
+
+ const isOverflowing = tabList.scrollWidth > tabList.clientWidth;
+
+ setShowOverflow(isOverflowing);
+
+ if (!isOverflowing) {
+ setHiddenTabs([]);
+
+ return;
+ }
+
+ const tabs = [...state.collection];
+ const hiddenTabsList: Array<{key: string; title: string}> = [];
+ const {left: containerLeft, right: containerRight} = tabList.getBoundingClientRect();
+
+ tabs.forEach((item) => {
+ const tabElement = tabList.querySelector(`[data-key="${item.key}"]`);
+
+ if (!tabElement) return;
+
+ const {left: tabLeft, right: tabRight} = tabElement.getBoundingClientRect();
+ const isHidden = tabRight > containerRight || tabLeft < containerLeft;
+
+ if (isHidden) {
+ hiddenTabsList.push({
+ key: String(item.key),
+ title: item.textValue || "",
+ });
+ }
+ });
+
+ setHiddenTabs(hiddenTabsList);
+ }, [state.collection, tabList]);
+
+ const scrollToTab = useCallback(
+ (key: string) => {
+ if (!tabList) return;
+
+ const tabElement = tabList.querySelector(`[data-key="${key}"]`);
+
+ if (!tabElement) return;
+
+ const tabBounds = tabElement.getBoundingClientRect();
+ const tabListBounds = tabList.getBoundingClientRect();
+
+ const targetScrollPosition =
+ tabList.scrollLeft +
+ (tabBounds.left - tabListBounds.left) -
+ tabListBounds.width / 2 +
+ tabBounds.width / 2;
+
+ tabList.scrollTo({
+ left: targetScrollPosition,
+ behavior: "smooth",
+ });
+ },
+ [tabList],
+ );
+
+ const handleTabSelect = useCallback(
+ (key: string) => {
+ state.setSelectedKey(key);
+ setIsDropdownOpen(false);
+
+ scrollToTab(key);
+ checkOverflow();
+ },
+ [state, scrollToTab, checkOverflow],
+ );
+
+ useEffect(() => {
+ if (!tabList) return;
+
+ tabList.style.overflowX = isDropdownOpen ? "hidden" : "auto";
+ }, [isDropdownOpen, tabListProps.ref]);
+
+ useEffect(() => {
+ const debouncedCheckOverflow = debounce(checkOverflow, 100);
+
+ debouncedCheckOverflow();
+
+ window.addEventListener("resize", debouncedCheckOverflow);
+
+ return () => {
+ window.removeEventListener("resize", debouncedCheckOverflow);
+ };
+ }, [checkOverflow]);
+
+ const MoreIcon = props.moreIcon || EllipsisIcon;
const tabsProps = {
state,
@@ -50,22 +153,52 @@ const Tabs = forwardRef(function Tabs(
const renderTabs = (
<>
-
+
{layoutGroupEnabled ? {tabs} : tabs}
+ {showOverflow && (
+
+
+
+
+ handleTabSelect(key as string)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setIsDropdownOpen(false);
+ }
+ }}
+ >
+ {hiddenTabs.map((tab) => (
+ {tab.title}
+ ))}
+
+
+ )}
- {[...state.collection].map((item) => {
- return (
-
- );
- })}
+ {[...state.collection].map((item) => (
+
+ ))}
>
);
diff --git a/packages/components/tabs/src/use-tabs.ts b/packages/components/tabs/src/use-tabs.ts
index cc071bb5e8..32cf6bc82a 100644
--- a/packages/components/tabs/src/use-tabs.ts
+++ b/packages/components/tabs/src/use-tabs.ts
@@ -12,6 +12,7 @@ import {mergeProps} from "@react-aria/utils";
import {CollectionProps} from "@heroui/aria-utils";
import {CollectionChildren} from "@react-types/shared";
import {HTMLMotionProps} from "framer-motion";
+import {ElementType} from "react";
export interface Props extends Omit {
/**
@@ -62,6 +63,10 @@ export interface Props extends Omit {
* @default true
*/
destroyInactiveTabPanel?: boolean;
+ /**
+ * Custom icon for the show more button
+ */
+ moreIcon?: ElementType;
}
export type UseTabsProps = Props &
diff --git a/packages/components/tabs/stories/tabs.stories.tsx b/packages/components/tabs/stories/tabs.stories.tsx
index 80cf2b4a6c..2bd5ca9112 100644
--- a/packages/components/tabs/stories/tabs.stories.tsx
+++ b/packages/components/tabs/stories/tabs.stories.tsx
@@ -12,6 +12,7 @@ import {
AlignLeftBoldIcon,
AlignRightBoldIcon,
AlignTopBoldIcon,
+ ChevronDownIcon,
} from "@heroui/shared-icons";
import {Tabs, Tab, TabsProps} from "../src";
@@ -81,6 +82,36 @@ const StaticTemplate = (args: TabsProps) => (
);
+const TabsWithNavigationButtonTemplate = (args: TabsProps) => (
+ <>
+
+ {[...Array(25)].map((_, index) => (
+
+
+
+ ))}
+
+
+ Custom More Icon
+
+ {[...Array(25)].map((_, index) => (
+
+
+
+ ))}
+
+
+ Tabs with 1000 items
+
+ {[...Array(1000)].map((_, index) => (
+
+
+
+ ))}
+
+ >
+);
+
const WithIconsTemplate = (args: TabsProps) => (
*
@@ -17,13 +17,30 @@ import {colorVariants, dataFocusVisibleClasses} from "../utils";
*
Tab 3
*
* Selected panel
+ *
*
* ```
+ *
+ * You can customize the "show more" button appearance:
+ * ```js
+ *
+ * tabs content
+ *
+ * ```
*/
const tabs = tv({
slots: {
- base: "inline-flex",
+ base: "relative flex w-full items-center gap-2",
tabList: [
+ "relative",
"flex",
"p-1",
"h-fit",
@@ -33,6 +50,8 @@ const tabs = tv({
"overflow-x-scroll",
"scrollbar-hide",
"bg-default-100",
+ "w-full",
+ "data-[has-overflow=true]:w-[calc(100%-32px)]",
],
tab: [
"z-0",
@@ -73,6 +92,17 @@ const tabs = tv({
...dataFocusVisibleClasses,
],
tabWrapper: [],
+ moreButton: [
+ "flex",
+ "items-center",
+ "justify-center",
+ "hover:bg-default-100",
+ "rounded-small",
+ "transition-colors",
+ "px-0",
+ "min-w-8",
+ ],
+ moreIcon: "w-5 h-5",
},
variants: {
variant: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d552419f4..4ffae907f2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -135,11 +135,7 @@ importers:
version: 6.0.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(babel-eslint@10.1.0(eslint@7.32.0))(eslint-plugin-flowtype@5.10.0(eslint@7.32.0))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jest@24.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3)
eslint-config-ts-lambdas:
specifier: ^1.2.3
- version: 1.2.3(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3)
- eslint-import-resolver-typescript:
- specifier: ^2.4.0
version: 2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0)
- eslint-loader:
specifier: ^4.0.2
version: 4.0.2(eslint@7.32.0)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1)))
eslint-plugin-import:
@@ -3087,6 +3083,9 @@ importers:
'@heroui/card':
specifier: workspace:*
version: link:../card
+ '@heroui/dropdown':
+ specifier: workspace:*
+ version: link:../dropdown
'@heroui/input':
specifier: workspace:*
version: link:../input
@@ -29001,7 +29000,7 @@ snapshots:
term-size@2.2.1: {}
- terser-webpack-plugin@5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1))):
+ terser-webpack-plugin@5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
@@ -29157,7 +29156,7 @@ snapshots:
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
- '@types/node': 20.2.5
+ '@types/node': 20.5.1
acorn: 8.14.0
acorn-walk: 8.3.4
arg: 4.1.3
@@ -29169,7 +29168,6 @@ snapshots:
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.10.6(@swc/helpers@0.5.15)
- optional: true
ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3):
dependencies: