Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/nine-moles-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@heroui/tabs": patch
---

fix unresponsive modal after switching tabs inside (#5543)
106 changes: 105 additions & 1 deletion packages/components/tabs/__tests__/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import type {UserEvent} from "@testing-library/user-event";
import type {TabsProps} from "../src";

import * as React from "react";
import {act, render, fireEvent, within} from "@testing-library/react";
import {act, render, fireEvent, within, waitFor} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {focus} from "@heroui/test-utils";
import {spy, shouldIgnoreReactWarning} from "@heroui/test-utils";
import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter} from "@heroui/modal";
import {Button} from "@heroui/button";

import {Tabs, Tab} from "../src";

Expand Down Expand Up @@ -435,4 +437,106 @@ describe("Tabs", () => {
expect(item2Click).toHaveBeenCalledTimes(2);
expect(tab2).toHaveAttribute("aria-selected", "true");
});

it("should allow reopening modal with tabs without blocking", async () => {
const TestComponent = () => {
const [isOpen, setIsOpen] = React.useState(false);

return (
<>
<Button data-testid="open-modal-btn" onPress={() => setIsOpen(true)}>
Open Modal
</Button>
<Modal data-testid="test-modal" isOpen={isOpen} onOpenChange={setIsOpen}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Test Modal with Tabs</ModalHeader>
<ModalBody>
<Tabs aria-label="Test tabs" data-testid="modal-tabs">
<Tab key="tab1" data-testid="tab-1" title="Tab 1">
<div data-testid="tab1-content">Content for Tab 1</div>
</Tab>
<Tab key="tab2" data-testid="tab-2" title="Tab 2">
<div data-testid="tab2-content">Content for Tab 2</div>
</Tab>
<Tab key="tab3" data-testid="tab-3" title="Tab 3">
<div data-testid="tab3-content">Content for Tab 3</div>
</Tab>
</Tabs>
</ModalBody>
<ModalFooter>
<Button data-testid="close-modal-btn" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
};

const {getByTestId, getByRole, queryByRole} = render(<TestComponent />);

const openButton = getByTestId("open-modal-btn");

await act(async () => {
fireEvent.click(openButton);
});

await waitFor(() => {
const modal = getByRole("dialog");

expect(modal).toBeInTheDocument();
});

const tabButtons = getByRole("dialog").querySelectorAll('[role="tab"]');

expect(tabButtons).toHaveLength(3);

await act(async () => {
fireEvent.click(tabButtons[1]);
});

await waitFor(() => {
expect(tabButtons[1]).toHaveAttribute("aria-selected", "true");
});

const closeButton = getByTestId("close-modal-btn");

await act(async () => {
fireEvent.click(closeButton);
});

await waitFor(
() => {
expect(queryByRole("dialog")).not.toBeInTheDocument();
},
{timeout: 1000},
);

await act(async () => {
fireEvent.click(openButton);
});

await waitFor(() => {
const modal = getByRole("dialog");

expect(modal).toBeInTheDocument();
});

const newTabButtons = getByRole("dialog").querySelectorAll('[role="tab"]');

expect(newTabButtons).toHaveLength(3);

await act(async () => {
fireEvent.click(newTabButtons[2]);
});

await waitFor(() => {
expect(newTabButtons[2]).toHaveAttribute("aria-selected", "true");
});
});
});
1 change: 1 addition & 0 deletions packages/components/tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@heroui/input": "workspace:*",
"@heroui/test-utils": "workspace:*",
"@heroui/button": "workspace:*",
"@heroui/modal": "workspace:*",
"@heroui/shared-icons": "workspace:*",
"clean-package": "2.2.0",
"react": "18.3.0",
Expand Down
4 changes: 3 additions & 1 deletion packages/components/tabs/src/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
rerender: true,
});

const isInModal = domRef?.current?.closest('[aria-modal="true"]') !== null;

const handleClick = () => {
if (!domRef?.current || !listRef?.current) return;

Expand Down Expand Up @@ -120,7 +122,7 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
title={otherProps?.titleValue}
type={Component === "button" ? "button" : undefined}
>
{isSelected && !disableAnimation && !disableCursorAnimation && isMounted ? (
{isSelected && !disableAnimation && !disableCursorAnimation && isMounted && !isInModal ? (
// use synchronous loading for domMax here
// since lazy loading produces different behaviour
<LazyMotion features={domMax}>
Expand Down
5 changes: 4 additions & 1 deletion packages/components/tabs/src/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const Tabs = forwardRef(function Tabs<T extends object>(
Component,
values,
state,
domRef,
destroyInactiveTabPanel,
getBaseProps,
getTabListProps,
Expand All @@ -32,7 +33,9 @@ const Tabs = forwardRef(function Tabs<T extends object>(

const layoutId = useId();

const layoutGroupEnabled = !props.disableAnimation && !props.disableCursorAnimation;
const isInModal = domRef?.current?.closest('[aria-modal="true"]') !== null;

const layoutGroupEnabled = !props.disableAnimation && !props.disableCursorAnimation && !isInModal;

const tabsProps = {
state,
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.