Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions .changeset/serious-eels-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@heroui/tabs": patch
"@heroui/theme": patch
---

responsive tab cursor
114 changes: 67 additions & 47 deletions packages/components/tabs/src/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {ForwardedRef, ReactElement} from "react";
import type {UseTabsProps} from "./use-tabs";

import {useRef, useMemo} from "react";
import {useEffect, useCallback, useRef, useMemo} from "react";
import {forwardRef} from "@heroui/system";

import {useTabs} from "./use-tabs";
Expand Down Expand Up @@ -44,74 +44,93 @@ const Tabs = forwardRef(function Tabs<T extends object>(
<Tab key={item.key} item={item} {...tabsProps} {...item.props} />
));

const selectedItem = state.selectedItem;
const selectedKey = selectedItem?.key;
const prevSelectedKey = useRef<typeof selectedKey>(undefined);
const prevVariant = useRef(props?.variant);
const variant = props?.variant;
const previousVariant = useRef<typeof variant>(undefined);
const isVertical = props?.isVertical;

const getCursorStyles = (tabRect: DOMRect, relativeLeft: number, relativeTop: number) => {
const baseStyles = {
left: `${relativeLeft}px`,
width: `${tabRect.width}px`,
};
const cursorRef = useRef<HTMLSpanElement | null>(null);
const selectedItem = state.selectedItem;
const selectedKey = selectedItem?.key;

const getCursorStyles = useCallback(
(tabRect: DOMRect) => {
if (variant === "underlined") {
return {
left: `${tabRect.left + tabRect.width * 0.1}px`,
top: `${tabRect.top + tabRect.height - 2}px`,
width: `${tabRect.width * 0.8}px`,
height: "",
};
}

if (variant === "underlined") {
return {
left: `${relativeLeft + tabRect.width * 0.1}px`,
top: `${relativeTop + tabRect.height - 2}px`,
width: `${tabRect.width * 0.8}px`,
height: "",
left: `${tabRect.left}px`,
top: `${tabRect.top}px`,
width: `${tabRect.width}px`,
height: `${tabRect.height}px`,
};
}

return {
...baseStyles,
top: `${relativeTop}px`,
height: `${tabRect.height}px`,
};
};

const updateCursorPosition = (node: HTMLSpanElement, selectedTab: HTMLElement) => {
const tabRect = {
width: selectedTab.offsetWidth,
height: selectedTab.offsetHeight,
} as DOMRect;
},
[variant],
);

const styles = getCursorStyles(tabRect, selectedTab.offsetLeft, selectedTab.offsetTop);
const updateCursorPosition = useCallback(
(selectedTab: HTMLElement) => {
if (!cursorRef.current) return;

const tabRect = {
width: selectedTab.offsetWidth,
height: selectedTab.offsetHeight,
left: selectedTab.offsetLeft,
top: selectedTab.offsetTop,
} as DOMRect;

const styles = getCursorStyles(tabRect);

if (variant !== previousVariant.current) {
cursorRef.current.removeAttribute("data-initialized");
}
cursorRef.current.style.left = styles.left;
cursorRef.current.style.top = styles.top;
cursorRef.current.style.width = styles.width;
cursorRef.current.style.height = styles.height;
previousVariant.current = variant;

requestAnimationFrame(() => cursorRef.current?.setAttribute("data-initialized", "true"));
},
[getCursorStyles, variant],
);

node.style.left = styles.left;
node.style.top = styles.top;
node.style.width = styles.width;
node.style.height = styles.height;
};
const onResize = useCallback(
(entries: ResizeObserverEntry[]) => {
const contentRect = entries[0].contentRect;

const handleCursorRef = (node: HTMLSpanElement | null) => {
if (!node) return;
// check if rendered
if (contentRect.width === 0 && contentRect.height === 0) return;

const selectedTab = domRef.current?.querySelector(`[data-key="${selectedKey}"]`) as HTMLElement;
updateCursorPosition(entries[0].target as HTMLElement);
},
[updateCursorPosition],
);

if (!selectedTab || !domRef.current) return;
useEffect(() => {
const selectedTab = domRef.current?.querySelector(`[data-key="${selectedKey}"]`);

const shouldDisableTransition =
prevSelectedKey.current === undefined || prevVariant.current !== variant;
if (!selectedTab) return;

node.style.transition = shouldDisableTransition ? "none" : "";
const observer = new ResizeObserver(onResize);

prevSelectedKey.current = selectedKey;
prevVariant.current = variant;
observer.observe(selectedTab);

updateCursorPosition(node, selectedTab);
};
return () => observer.disconnect();
}, [domRef, onResize, selectedKey]);

const renderTabs = useMemo(
() => (
<>
<div {...getBaseProps()}>
<Component {...getTabListProps()}>
{!values.disableAnimation && !values.disableCursorAnimation && selectedKey != null && (
<span {...getTabCursorProps()} ref={handleCursorRef} />
<span {...getTabCursorProps()} ref={cursorRef} />
)}
{tabs}
</Component>
Expand Down Expand Up @@ -145,6 +164,7 @@ const Tabs = forwardRef(function Tabs<T extends object>(
values.state,
destroyInactiveTabPanel,
domRef,
cursorRef,
variant,
isVertical,
],
Expand Down
8 changes: 5 additions & 3 deletions packages/core/theme/src/components/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ const tabs = tv({
"z-0",
"bg-white",
"will-change-[transform,width,height]",
"transition-[left,top,width,height]",
"duration-250",
"ease-out",
"invisible",
"data-[initialized=true]:visible",
"data-[initialized=true]:transition-[left,top,width,height]",
"data-[initialized=true]:duration-250",
"data-[initialized=true]:ease-out",
],
panel: [
"py-3",
Expand Down