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: