diff --git a/.changeset/late-trainers-switch.md b/.changeset/late-trainers-switch.md new file mode 100644 index 000000000..e39670271 --- /dev/null +++ b/.changeset/late-trainers-switch.md @@ -0,0 +1,6 @@ +--- +"@heroui/tabs": patch +"@heroui/theme": patch +--- + +fix tabs in modal (#5657) diff --git a/apps/docs/content/docs/components/tabs.mdx b/apps/docs/content/docs/components/tabs.mdx index d4bb3153f..7c6cc9dd2 100644 --- a/apps/docs/content/docs/components/tabs.mdx +++ b/apps/docs/content/docs/components/tabs.mdx @@ -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", diff --git a/packages/components/tabs/package.json b/packages/components/tabs/package.json index ece12a0cb..a18764d0b 100644 --- a/packages/components/tabs/package.json +++ b/packages/components/tabs/package.json @@ -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": { diff --git a/packages/components/tabs/src/tab.tsx b/packages/components/tabs/src/tab.tsx index d346c545d..f7abbee7a 100644 --- a/packages/components/tabs/src/tab.tsx +++ b/packages/components/tabs/src/tab.tsx @@ -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 extends BaseTabItemProps { item: Node; @@ -19,9 +17,6 @@ export interface TabItemProps 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 - - - - ) : null}
( getBaseProps, getTabListProps, getWrapperProps, + getTabCursorProps, } = useTabs({ ...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) => ( )); - const renderTabs = ( - <> -
- - {layoutGroupEnabled ? {tabs} : tabs} - -
- {[...state.collection].map((item) => { - return ( - - ); - })} - + const selectedItem = state.selectedItem; + const selectedKey = selectedItem?.key; + const prevSelectedKey = useRef(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( + () => ( + <> +
+ + {!values.disableAnimation && !values.disableCursorAnimation && selectedKey != null && ( + + )} + {tabs} + +
+ {[...state.collection].map((item) => { + return ( + + ); + })} + + ), + [ + 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) { diff --git a/packages/components/tabs/src/use-tabs.ts b/packages/components/tabs/src/use-tabs.ts index 15a5b3527..fc5fe9f1a 100644 --- a/packages/components/tabs/src/use-tabs.ts +++ b/packages/components/tabs/src/use-tabs.ts @@ -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 { * Ref to the DOM node. */ ref?: ReactRef; - /** - * The props to modify the cursor motion animation. Use the `variants` API to create your own animation. - */ - motionProps?: Omit, "ref">; /** * Whether the tabs selection should occur on press up instead of press down. * @default true @@ -81,7 +76,6 @@ export type ValuesType = { listRef?: RefObject; shouldSelectOnPressUp?: boolean; classNames?: SlotsToClasses; - motionProps?: Omit, "ref">; disableAnimation?: boolean; isDisabled?: boolean; }; @@ -98,7 +92,6 @@ export function useTabs(originalProps: UseTabsProps) { classNames, children, disableCursorAnimation, - motionProps, isVertical = false, shouldSelectOnPressUp = true, destroyInactiveTabPanel = true, @@ -136,7 +129,6 @@ export function useTabs(originalProps: UseTabsProps) { state, slots, classNames, - motionProps, disableAnimation, listRef: domRef, shouldSelectOnPressUp, @@ -147,7 +139,6 @@ export function useTabs(originalProps: UseTabsProps) { state, slots, domRef, - motionProps, disableAnimation, disableCursorAnimation, shouldSelectOnPressUp, @@ -192,6 +183,16 @@ export function useTabs(originalProps: UseTabsProps) { [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(originalProps: UseTabsProps) { getBaseProps, getTabListProps, getWrapperProps, + getTabCursorProps, }; } diff --git a/packages/core/theme/src/components/tabs.ts b/packages/core/theme/src/components/tabs.ts index 065b0091f..848abcbe5 100644 --- a/packages/core/theme/src/components/tabs.ts +++ b/packages/core/theme/src/components/tabs.ts @@ -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",