Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tabs): Add ellipsis for multiple tabs #4510

Open
wants to merge 27 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
98a8348
feat: added ellipsis dropdown in tabs
deepansh946 Jan 7, 2025
1790c35
fix: tests
deepansh946 Jan 7, 2025
0ed340e
chore: added changeset & some refactor
deepansh946 Jan 7, 2025
f3683d6
fix: lock file
deepansh946 Jan 7, 2025
acd7057
chore(changeset): update package name
wingkwong Jan 18, 2025
6823cf4
chore: added multiple tabs story & updated changeset
deepansh946 Jan 23, 2025
2df0fdd
fix: useEffect deps & some UI updates
deepansh946 Jan 23, 2025
b71f011
fix: tests
deepansh946 Jan 23, 2025
07fdf26
fix: merge conflicts
deepansh946 Jan 23, 2025
0bfa233
feat: added ellipsis dropdown in tabs
deepansh946 Jan 7, 2025
11b2b9a
fix: tests
deepansh946 Jan 7, 2025
8b27006
chore: added changeset & some refactor
deepansh946 Jan 7, 2025
3e1fc95
fix: lock file
deepansh946 Jan 7, 2025
b6e3349
chore(changeset): update package name
wingkwong Jan 18, 2025
2ba85c7
chore: added multiple tabs story & updated changeset
deepansh946 Jan 23, 2025
31ebb0d
fix: useEffect deps & some UI updates
deepansh946 Jan 23, 2025
1324f40
fix: tests
deepansh946 Jan 23, 2025
1724aad
fix: merge conflicts
deepansh946 Jan 23, 2025
367ea76
Merge branch 'feat/dropdown-in-tabs' of github.com:deepansh946/nextui…
deepansh946 Jan 30, 2025
bb89a9c
Merge branch 'canary' into feat/dropdown-in-tabs
deepansh946 Jan 30, 2025
2adbc8b
refactor: tabs & tests code
deepansh946 Jan 30, 2025
6b7e4ff
fix: tab scroll & btn element
deepansh946 Feb 10, 2025
b519b2a
refactor: added documentation & tab list props
deepansh946 Feb 10, 2025
b56e81e
fix: story name & example
deepansh946 Feb 10, 2025
97553ca
refactor: removed extra classes
deepansh946 Feb 10, 2025
2201762
feat: add scrollable dropdown menu & story with many tabs
deepansh946 Feb 10, 2025
26cdc42
Merge branch 'canary' into feat/dropdown-in-tabs
deepansh946 Feb 20, 2025
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
5 changes: 5 additions & 0 deletions .changeset/curly-snails-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@heroui/tabs": patch
---

Added ellipsis to tabs (#3573)
2 changes: 2 additions & 0 deletions apps/docs/content/components/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,4 +29,5 @@ export const tabsContent = {
customStyles,
placement,
vertical,
overflow,
};
17 changes: 17 additions & 0 deletions apps/docs/content/components/tabs/overflow.raw.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {Tabs, Tab, Card, CardBody} from "@heroui/react";

export default function App() {
return (
<div className="flex w-full flex-col">
<Tabs aria-label="Tabs with overflow">
{Array.from({length: 20}, (_, i) => (
<Tab key={i + 1} title={`Tab ${i + 1}`}>
<Card>
<CardBody>Content for tab {i + 1}</CardBody>
</Card>
</Tab>
))}
</Tabs>
</div>
);
}
9 changes: 9 additions & 0 deletions apps/docs/content/components/tabs/overflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import App from "./overflow.raw.jsx?raw";

const react = {
"/App.jsx": App,
};

export default {
...react,
};
27 changes: 26 additions & 1 deletion apps/docs/content/docs/components/tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ Change the orientation of the tabs it will invalidate the placement prop when th

<CodeDemo title="Vertical" files={tabsContent.vertical} />

### 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

<CodeDemo title="Overflow" files={tabsContent.overflow} />

You can customize the overflow button appearance:

```jsx
<Tabs
moreIcon={CustomIcon}
classNames={{
moreButton: "custom-button-styles",
moreIcon: "custom-icon-styles"
}}
>
// ... tabs content
</Tabs>
```

### Links

Tabs items can be rendered as links by passing the `href` prop to the `Tab` component. By
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -344,7 +369,7 @@ You can customize the `Tabs` component by passing custom Tailwind CSS classes to
},
{
attribute: "classNames",
type: "Partial<Record<\"base\"| \"tabList\"| \"tab\"| \"tabContent\"| \"cursor\" | \"panel\" | \"tabWrapper\", string>>",
type: "Partial<Record<\"base\"| \"tabList\"| \"tab\"| \"tabContent\"| \"cursor\" | \"panel\" | \"tabWrapper\" | \"moreButton\" | \"moreIcon\", string>>",
description: "Allows to set custom class names for the card slots.",
default: "-"
},
Expand Down
101 changes: 101 additions & 0 deletions packages/components/tabs/__tests__/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Tabs aria-label="Tabs">
<Tab key="1" title="Tab 1">
Content 1
</Tab>
<Tab key="2" title="Tab 2">
Content 2
</Tab>
<Tab key="3" title="Tab 3">
Content 3
</Tab>
<Tab key="4" title="Tab 4">
Content 4
</Tab>
</Tabs>,
);

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(
<Tabs aria-label="Tabs">
<Tab key="1" title="Tab 1">
Content 1
</Tab>
<Tab key="2" title="Tab 2">
Content 2
</Tab>
</Tabs>,
);

const tabList = container.querySelector('[role="tablist"]') as HTMLElement;

mockTabListDimensions(tabList, false);
mockTabPositions(tabList);

fireEvent.scroll(tabList);

expect(queryByLabelText("Show more tabs")).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions packages/components/tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
161 changes: 147 additions & 14 deletions packages/components/tabs/src/tabs.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,8 +33,106 @@ const Tabs = forwardRef(function Tabs<T extends object>(
});

const layoutId = useId();
const [showOverflow, setShowOverflow] = useState(false);
const [hiddenTabs, setHiddenTabs] = useState<Array<{key: string; title: string}>>([]);
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,
Expand All @@ -50,22 +153,52 @@ const Tabs = forwardRef(function Tabs<T extends object>(
const renderTabs = (
<>
<div {...getBaseProps()}>
<Component {...getTabListProps()}>
<Component {...tabListProps} onScroll={checkOverflow}>
{layoutGroupEnabled ? <LayoutGroup id={layoutId}>{tabs}</LayoutGroup> : tabs}
</Component>
{showOverflow && (
<Dropdown>
<DropdownTrigger>
<Button
aria-label="Show more tabs"
className={clsx(values.slots.moreButton(), values.classNames?.moreButton)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsDropdownOpen(true);
}
}}
>
<MoreIcon className={clsx(values.slots.moreIcon(), values.classNames?.moreIcon)} />
<span className="sr-only">More tabs</span>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Hidden tabs"
className="max-h-[300px] overflow-y-auto"
onAction={(key) => handleTabSelect(key as string)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setIsDropdownOpen(false);
}
}}
>
{hiddenTabs.map((tab) => (
<DropdownItem key={tab.key}>{tab.title}</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
)}
</div>
{[...state.collection].map((item) => {
return (
<TabPanel
key={item.key}
classNames={values.classNames}
destroyInactiveTabPanel={destroyInactiveTabPanel}
slots={values.slots}
state={values.state}
tabKey={item.key}
/>
);
})}
{[...state.collection].map((item) => (
<TabPanel
key={item.key}
classNames={values.classNames}
destroyInactiveTabPanel={destroyInactiveTabPanel}
slots={values.slots}
state={values.state}
tabKey={item.key}
/>
))}
</>
);

Expand Down
Loading
Loading