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.", description: "Whether tabs are activated automatically on focus or manually.",
default: "automatic" 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", attribute: "disableCursorAnimation",
type: "boolean", type: "boolean",

View File

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

View File

@ -9,8 +9,6 @@ import scrollIntoView from "scroll-into-view-if-needed";
import {useFocusRing} from "@react-aria/focus"; import {useFocusRing} from "@react-aria/focus";
import {useTab} from "@react-aria/tabs"; import {useTab} from "@react-aria/tabs";
import {useHover} from "@react-aria/interactions"; 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> { export interface TabItemProps<T extends object = object> extends BaseTabItemProps<T> {
item: Node<T>; item: Node<T>;
@ -19,9 +17,6 @@ export interface TabItemProps<T extends object = object> extends BaseTabItemProp
listRef?: ValuesType["listRef"]; listRef?: ValuesType["listRef"];
classNames?: ValuesType["classNames"]; classNames?: ValuesType["classNames"];
isDisabled?: ValuesType["isDisabled"]; 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, isDisabled: isDisabledProp,
listRef, listRef,
slots, slots,
motionProps,
disableAnimation,
disableCursorAnimation,
shouldSelectOnPressUp, shouldSelectOnPressUp,
tabRef, tabRef,
...otherProps ...otherProps
@ -72,12 +64,6 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
const tabStyles = clsx(classNames?.tab, className); const tabStyles = clsx(classNames?.tab, className);
const [, isMounted] = useIsMounted({
rerender: true,
});
const isInModal = domRef?.current?.closest('[aria-modal="true"]') !== null;
const handleClick = () => { const handleClick = () => {
if (!domRef?.current || !listRef?.current) return; if (!domRef?.current || !listRef?.current) return;
@ -98,6 +84,7 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
data-focus-visible={dataAttr(isFocusVisible)} data-focus-visible={dataAttr(isFocusVisible)}
data-hover={dataAttr(isHovered)} data-hover={dataAttr(isHovered)}
data-hover-unselected={dataAttr((isHovered || isPressed) && !isSelected)} data-hover-unselected={dataAttr((isHovered || isPressed) && !isSelected)}
data-key={key}
data-pressed={dataAttr(isPressed)} data-pressed={dataAttr(isPressed)}
data-selected={dataAttr(isSelected)} data-selected={dataAttr(isSelected)}
data-slot="tab" data-slot="tab"
@ -122,24 +109,6 @@ const Tab = forwardRef<"button", TabItemProps>((props, ref) => {
title={otherProps?.titleValue} title={otherProps?.titleValue}
type={Component === "button" ? "button" : undefined} 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 <div
className={slots.tabContent({ className={slots.tabContent({
class: classNames?.tabContent, class: classNames?.tabContent,

View File

@ -1,8 +1,7 @@
import type {ForwardedRef, ReactElement} from "react"; import type {ForwardedRef, ReactElement} from "react";
import type {UseTabsProps} from "./use-tabs"; import type {UseTabsProps} from "./use-tabs";
import {useId} from "react"; import {useRef, useMemo} from "react";
import {LayoutGroup} from "framer-motion";
import {forwardRef} from "@heroui/system"; import {forwardRef} from "@heroui/system";
import {useTabs} from "./use-tabs"; import {useTabs} from "./use-tabs";
@ -26,53 +25,145 @@ const Tabs = forwardRef(function Tabs<T extends object>(
getBaseProps, getBaseProps,
getTabListProps, getTabListProps,
getWrapperProps, getWrapperProps,
getTabCursorProps,
} = useTabs<T>({ } = useTabs<T>({
...props, ...props,
ref, ref,
}); });
const layoutId = useId();
const isInModal = domRef?.current?.closest('[aria-modal="true"]') !== null;
const layoutGroupEnabled = !props.disableAnimation && !props.disableCursorAnimation && !isInModal;
const tabsProps = { const tabsProps = {
state, state,
listRef: values.listRef, listRef: values.listRef,
slots: values.slots, slots: values.slots,
classNames: values.classNames, classNames: values.classNames,
isDisabled: values.isDisabled, isDisabled: values.isDisabled,
motionProps: values.motionProps,
disableAnimation: values.disableAnimation,
shouldSelectOnPressUp: values.shouldSelectOnPressUp, shouldSelectOnPressUp: values.shouldSelectOnPressUp,
disableCursorAnimation: values.disableCursorAnimation,
}; };
const tabs = [...state.collection].map((item) => ( const tabs = [...state.collection].map((item) => (
<Tab key={item.key} item={item} {...tabsProps} {...item.props} /> <Tab key={item.key} item={item} {...tabsProps} {...item.props} />
)); ));
const renderTabs = ( const selectedItem = state.selectedItem;
<> const selectedKey = selectedItem?.key;
<div {...getBaseProps()}> const prevSelectedKey = useRef<typeof selectedKey>(undefined);
<Component {...getTabListProps()}> const prevVariant = useRef(props?.variant);
{layoutGroupEnabled ? <LayoutGroup id={layoutId}>{tabs}</LayoutGroup> : tabs} const variant = props?.variant;
</Component> const isVertical = props?.isVertical;
</div>
{[...state.collection].map((item) => { const getCursorStyles = (tabRect: DOMRect, relativeLeft: number, relativeTop: number) => {
return ( const baseStyles = {
<TabPanel left: `${relativeLeft}px`,
key={item.key} width: `${tabRect.width}px`,
classNames={values.classNames} };
destroyInactiveTabPanel={destroyInactiveTabPanel}
slots={values.slots} if (variant === "underlined") {
state={values.state} return {
tabKey={item.key} 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) { 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 {AriaTabListProps} from "@react-aria/tabs";
import type {CollectionProps} from "@heroui/aria-utils"; import type {CollectionProps} from "@heroui/aria-utils";
import type {CollectionChildren} from "@react-types/shared"; import type {CollectionChildren} from "@react-types/shared";
import type {HTMLMotionProps} from "framer-motion";
import type {HTMLHeroUIProps, PropGetter} from "@heroui/system"; import type {HTMLHeroUIProps, PropGetter} from "@heroui/system";
import {mapPropsVariants, useProviderContext} 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 to the DOM node.
*/ */
ref?: ReactRef<HTMLElement | null>; 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. * Whether the tabs selection should occur on press up instead of press down.
* @default true * @default true
@ -81,7 +76,6 @@ export type ValuesType<T = object> = {
listRef?: RefObject<HTMLElement>; listRef?: RefObject<HTMLElement>;
shouldSelectOnPressUp?: boolean; shouldSelectOnPressUp?: boolean;
classNames?: SlotsToClasses<TabsSlots>; classNames?: SlotsToClasses<TabsSlots>;
motionProps?: Omit<HTMLMotionProps<"span">, "ref">;
disableAnimation?: boolean; disableAnimation?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
}; };
@ -98,7 +92,6 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
classNames, classNames,
children, children,
disableCursorAnimation, disableCursorAnimation,
motionProps,
isVertical = false, isVertical = false,
shouldSelectOnPressUp = true, shouldSelectOnPressUp = true,
destroyInactiveTabPanel = true, destroyInactiveTabPanel = true,
@ -136,7 +129,6 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
state, state,
slots, slots,
classNames, classNames,
motionProps,
disableAnimation, disableAnimation,
listRef: domRef, listRef: domRef,
shouldSelectOnPressUp, shouldSelectOnPressUp,
@ -147,7 +139,6 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
state, state,
slots, slots,
domRef, domRef,
motionProps,
disableAnimation, disableAnimation,
disableCursorAnimation, disableCursorAnimation,
shouldSelectOnPressUp, shouldSelectOnPressUp,
@ -192,6 +183,16 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
[domRef, tabListProps, classNames, slots], [domRef, tabListProps, classNames, slots],
); );
const getTabCursorProps: PropGetter = useCallback(
(props) => ({
"data-slot": "cursor",
className: slots.cursor({
class: clsx(classNames?.cursor, props?.className),
}),
}),
[classNames, slots],
);
return { return {
Component, Component,
domRef, domRef,
@ -201,6 +202,7 @@ export function useTabs<T extends object>(originalProps: UseTabsProps<T>) {
getBaseProps, getBaseProps,
getTabListProps, getTabListProps,
getWrapperProps, getWrapperProps,
getTabCursorProps,
}; };
} }

View File

@ -24,6 +24,7 @@ const tabs = tv({
slots: { slots: {
base: "inline-flex", base: "inline-flex",
tabList: [ tabList: [
"relative",
"flex", "flex",
"p-1", "p-1",
"h-fit", "h-fit",
@ -63,7 +64,15 @@ const tabs = tv({
"text-default-500", "text-default-500",
"group-data-[selected=true]:text-foreground", "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: [ panel: [
"py-3", "py-3",
"px-1", "px-1",