fix(tabs): tabs in modal (#5705)

* fix(tabs): tabs in modal

* refactor(tabs): revise tabs cursor

* refactor(theme): remove unused styles

* fix(tabs): revise cursor rendering logic

* chore(docs): remove motionProps

* refactor(tabs): cursor styles

* refactor(tabs): cursor styles

* fix(tabs): avoid transition on variant change

* refactor(theme): cursor styles

* chore(changeset): include theme package

* chore(tabs): bump peerDeps on theme pkg

* fix(tabs): revise underlined cursor styles
This commit is contained in:
WK 2025-10-04 13:39:20 +08:00 committed by GitHub
parent 7537226b54
commit 6eba109a7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 151 additions and 80 deletions

View File

@ -0,0 +1,6 @@
---
"@heroui/tabs": patch
"@heroui/theme": patch
---
fix tabs in modal (#5657)

View File

@ -318,12 +318,6 @@ You can customize the `Tabs` component by passing custom Tailwind CSS classes to
description: "Whether tabs are activated automatically on focus or manually.",
default: "automatic"
},
{
attribute: "motionProps",
type: "MotionProps",
description: "The props to modify the cursor framer motion animation. Use the variants API to create your own animation.",
default: "-"
},
{
attribute: "disableCursorAnimation",
type: "boolean",

View File

@ -37,7 +37,7 @@
"react": ">=18 || >=19.0.0-rc.0",
"react-dom": ">=18 || >=19.0.0-rc.0",
"framer-motion": ">=11.5.6 || >=12.0.0-alpha.1",
"@heroui/theme": ">=2.4.17",
"@heroui/theme": ">=2.4.22",
"@heroui/system": ">=2.4.18"
},
"dependencies": {

View File

@ -9,8 +9,6 @@ import scrollIntoView from "scroll-into-view-if-needed";
import {useFocusRing} from "@react-aria/focus";
import {useTab} from "@react-aria/tabs";
import {useHover} from "@react-aria/interactions";
import {m, domMax, LazyMotion} from "framer-motion";
import {useIsMounted} from "@heroui/use-is-mounted";
export interface TabItemProps<T extends object = object> extends BaseTabItemProps<T> {
item: Node<T>;
@ -19,9 +17,6 @@ export interface TabItemProps<T extends object = object> extends BaseTabItemProp
listRef?: ValuesType["listRef"];
classNames?: ValuesType["classNames"];
isDisabled?: ValuesType["isDisabled"];
motionProps?: ValuesType["motionProps"];
disableAnimation?: ValuesType["disableAnimation"];
disableCursorAnimation?: ValuesType["disableCursorAnimation"];
}
/**
@ -37,9 +32,6 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
isDisabled: isDisabledProp,
listRef,
slots,
motionProps,
disableAnimation,
disableCursorAnimation,
shouldSelectOnPressUp,
tabRef,
...otherProps
@ -72,12 +64,6 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
const tabStyles = clsx(classNames?.tab, className);
const [, isMounted] = useIsMounted({
rerender: true,
});
const isInModal = domRef?.current?.closest('[aria-modal="true"]') !== null;
const handleClick = () => {
if (!domRef?.current || !listRef?.current) return;
@ -98,6 +84,7 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
data-focus-visible={dataAttr(isFocusVisible)}
data-hover={dataAttr(isHovered)}
data-hover-unselected={dataAttr((isHovered || isPressed) && !isSelected)}
data-key={key}
data-pressed={dataAttr(isPressed)}
data-selected={dataAttr(isSelected)}
data-slot="tab"
@ -122,24 +109,6 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
title={otherProps?.titleValue}
type={Component === "button" ? "button" : undefined}
>
{isSelected && !disableAnimation && !disableCursorAnimation && isMounted && !isInModal ? (
// use synchronous loading for domMax here
// since lazy loading produces different behaviour
<LazyMotion features={domMax}>
<m.span
className={slots.cursor({class: classNames?.cursor})}
data-slot="cursor"
layoutDependency={false}
layoutId="cursor"
transition={{
type: "spring",
bounce: 0.15,
duration: 0.5,
}}
{...motionProps}
/>
</LazyMotion>
) : null}
<div
className={slots.tabContent({
class: classNames?.tabContent,

View File

@ -1,8 +1,7 @@
import type {ForwardedRef, ReactElement} from "react";
import type {UseTabsProps} from "./use-tabs";
import {useId} from "react";
import {LayoutGroup} from "framer-motion";
import {useRef, useMemo} from "react";
import {forwardRef} from "@heroui/system";
import {useTabs} from "./use-tabs";
@ -26,53 +25,145 @@ const Tabs = forwardRef(function Tabs<T extends object>(
getBaseProps,
getTabListProps,
getWrapperProps,
getTabCursorProps,
} = useTabs<T>({
...props,
ref,
});
const layoutId = useId();
const isInModal = domRef?.current?.closest('[aria-modal="true"]') !== null;
const layoutGroupEnabled = !props.disableAnimation && !props.disableCursorAnimation && !isInModal;
const tabsProps = {
state,
listRef: values.listRef,
slots: values.slots,
classNames: values.classNames,
isDisabled: values.isDisabled,
motionProps: values.motionProps,
disableAnimation: values.disableAnimation,
shouldSelectOnPressUp: values.shouldSelectOnPressUp,
disableCursorAnimation: values.disableCursorAnimation,
};
const tabs = [...state.collection].map((item) => (
<Tab key={item.key} item={item} {...tabsProps} {...item.props} />
));
const renderTabs = (
<>
<div {...getBaseProps()}>
<Component {...getTabListProps()}>
{layoutGroupEnabled ? <LayoutGroup id={layoutId}>{tabs}</LayoutGroup> : tabs}
</Component>
</div>
{[...state.collection].map((item) => {
return (
<TabPanel
key={item.key}
classNames={values.classNames}
destroyInactiveTabPanel={destroyInactiveTabPanel}
slots={values.slots}
state={values.state}
tabKey={item.key}
/>
);
})}
</>
const selectedItem = state.selectedItem;
const selectedKey = selectedItem?.key;
const prevSelectedKey = useRef<typeof selectedKey>(undefined);
const prevVariant = useRef(props?.variant);
const variant = props?.variant;
const isVertical = props?.isVertical;
const getCursorStyles = (tabRect: DOMRect, relativeLeft: number, relativeTop: number) => {
const baseStyles = {
left: `${relativeLeft}px`,
width: `${tabRect.width}px`,
};
if (variant === "underlined") {
return {
left: `${relativeLeft + tabRect.width * 0.1}px`,
top: `${relativeTop + tabRect.height - 2}px`,
width: `${tabRect.width * 0.8}px`,
height: "",
};
}
if (variant === "bordered") {
const borderWidth = 2;
return {
...baseStyles,
top: `${relativeTop - borderWidth}px`,
width: `${tabRect.width - borderWidth}px`,
height: `${tabRect.height}px`,
};
}
return {
...baseStyles,
top: `${relativeTop}px`,
height: `${tabRect.height}px`,
};
};
const updateCursorPosition = (
node: HTMLSpanElement,
selectedTab: HTMLElement,
parentRect: DOMRect,
) => {
const tabRect = selectedTab.getBoundingClientRect();
const relativeLeft = tabRect.left - parentRect.left;
const relativeTop = tabRect.top - parentRect.top;
const styles = getCursorStyles(tabRect, relativeLeft, relativeTop);
node.style.left = styles.left;
node.style.top = styles.top;
node.style.width = styles.width;
node.style.height = styles.height;
};
const handleCursorRef = (node: HTMLSpanElement | null) => {
if (!node) return;
const selectedTab = domRef.current?.querySelector(`[data-key="${selectedKey}"]`) as HTMLElement;
if (!selectedTab || !domRef.current) return;
const shouldDisableTransition =
prevSelectedKey.current === undefined || prevVariant.current !== variant;
node.style.transition = shouldDisableTransition ? "none" : "";
prevSelectedKey.current = selectedKey;
prevVariant.current = variant;
const parentRect = domRef.current.getBoundingClientRect();
updateCursorPosition(node, selectedTab, parentRect);
};
const renderTabs = useMemo(
() => (
<>
<div {...getBaseProps()}>
<Component {...getTabListProps()}>
{!values.disableAnimation && !values.disableCursorAnimation && selectedKey != null && (
<span {...getTabCursorProps()} ref={handleCursorRef} />
)}
{tabs}
</Component>
</div>
{[...state.collection].map((item) => {
return (
<TabPanel
key={item.key}
classNames={values.classNames}
destroyInactiveTabPanel={destroyInactiveTabPanel}
slots={values.slots}
state={values.state}
tabKey={item.key}
/>
);
})}
</>
),
[
Component,
getBaseProps,
getTabListProps,
getTabCursorProps,
tabs,
selectedKey,
state.collection,
values.disableAnimation,
values.disableCursorAnimation,
values.classNames,
values.slots,
values.state,
destroyInactiveTabPanel,
domRef,
variant,
isVertical,
],
);
if ("placement" in props || "isVertical" in props) {

View File

@ -5,7 +5,6 @@ import type {TabListState, TabListStateOptions} from "@react-stately/tabs";
import type {AriaTabListProps} from "@react-aria/tabs";
import type {CollectionProps} from "@heroui/aria-utils";
import type {CollectionChildren} from "@react-types/shared";
import type {HTMLMotionProps} from "framer-motion";
import type {HTMLHeroUIProps, PropGetter} from "@heroui/system";
import {mapPropsVariants, useProviderContext} from "@heroui/system";
@ -22,10 +21,6 @@ export interface Props extends Omit<HTMLHeroUIProps, "children"> {
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLElement | null>;
/**
* The props to modify the cursor motion animation. Use the `variants` API to create your own animation.
*/
motionProps?: Omit<HTMLMotionProps<"span">, "ref">;
/**
* Whether the tabs selection should occur on press up instead of press down.
* @default true
@ -81,7 +76,6 @@ export type ValuesType<T = object> = {
listRef?: RefObject<HTMLElement>;
shouldSelectOnPressUp?: boolean;
classNames?: SlotsToClasses<TabsSlots>;
motionProps?: Omit<HTMLMotionProps<"span">, "ref">;
disableAnimation?: boolean;
isDisabled?: boolean;
};
@ -98,7 +92,6 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
classNames,
children,
disableCursorAnimation,
motionProps,
isVertical = false,
shouldSelectOnPressUp = true,
destroyInactiveTabPanel = true,
@ -136,7 +129,6 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
state,
slots,
classNames,
motionProps,
disableAnimation,
listRef: domRef,
shouldSelectOnPressUp,
@ -147,7 +139,6 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
state,
slots,
domRef,
motionProps,
disableAnimation,
disableCursorAnimation,
shouldSelectOnPressUp,
@ -192,6 +183,16 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
[domRef, tabListProps, classNames, slots],
);
const getTabCursorProps: PropGetter = useCallback(
(props) => ({
"data-slot": "cursor",
className: slots.cursor({
class: clsx(classNames?.cursor, props?.className),
}),
}),
[classNames, slots],
);
return {
Component,
domRef,
@ -201,6 +202,7 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
getBaseProps,
getTabListProps,
getWrapperProps,
getTabCursorProps,
};
}

View File

@ -24,6 +24,7 @@ const tabs = tv({
slots: {
base: "inline-flex",
tabList: [
"relative",
"flex",
"p-1",
"h-fit",
@ -63,7 +64,15 @@ const tabs = tv({
"text-default-500",
"group-data-[selected=true]:text-foreground",
],
cursor: ["absolute", "z-0", "bg-white"],
cursor: [
"absolute",
"z-0",
"bg-white",
"will-change-[transform,width,height]",
"transition-[left,top,width,height]",
"duration-250",
"ease-out",
],
panel: [
"py-3",
"px-1",