Tab
Categorystyled
ArchitectureReact
Client
Component
Dependencies
Usage
Preview of the component
some code
Which its height might differ
import { CodeBracketIcon, TvIcon } from "@heroicons/react/24/solid";
import { Tab, TabItem } from "@/components/react/styled/tab";
import { DemoWrapper } from "./demoWrapper";
export const TabDemo = ({ code }: { code: string }) => {
return (
<DemoWrapper code={code}>
<Tab className="min-h-[40vh] flex flex-col justify-center align-middle items-stretch content-stretch">
<TabItem id="t-1" title="Preview" icon={<TvIcon className="w-4 h-4" />}>
<p className="text-center">Preview of the component</p>
</TabItem>
<TabItem
id="t-0"
title="Code"
icon={<CodeBracketIcon className="w-4 h-4" />}
>
<p className="text-center">some code</p>
<p className="text-center">Which its height might differ</p>
</TabItem>
</Tab>
</DemoWrapper>
);
};
Install Instructions
Verifying dependencies
Make sure the following dependencies are satisfied:
- segmentedControl
Copy and paste the code into that file
import {
Children,
type ComponentPropsWithoutRef,
type ElementType,
type ReactNode,
createElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
// Sub-Components
import {
SegmentedControl,
type SegmentedControlProps,
} from "@/components/react/styled/segmentedControl";
/* ------ Internal ------ */
interface TabItemProps {
id: string;
children: ReactNode;
title: string;
icon?: ReactNode;
index: number;
}
interface TabPropsBase<T extends ElementType> {
wrapperComponent?: T;
}
type TabProps<T extends ElementType> = TabPropsBase<T> &
Omit<ComponentPropsWithoutRef<T>, keyof TabPropsBase<T>>;
type TabItemInternalProps = Omit<TabItemProps, "title">;
const TabItemInternal = ({ children, id }: TabItemInternalProps) => {
return (
<div
data-pos="initial" // Values: initial - center - left - right
id={`tab-item-${id}`}
className={[
"rounded-[calc(theme(borderRadius.3xl)_-_theme(spacing.3))]",
"overflow-hidden",
"[transition:_transform_300ms_ease-in-out,_opacity_300ms_ease-in-out]",
"duration-300",
"w-[calc(100%_-_theme(space.6))]",
"m-3",
"opacity-0",
// Initial states
"[&:not([data-pos=initial])]:absolute",
"data-[pos=initial]:first:static",
"data-[pos=initial]:first:opacity-100",
"data-[pos=initial]:absolute",
"data-[pos=initial]:opacity-0",
"data-[pos=initial]:pointer-events-none",
// Hydrated states
"data-[pos=center]:opacity-100",
"data-[pos=center]:translate-x-0",
"motion-safe:data-[pos=left]:translate-x-[-100%]",
"motion-safe:data-[pos=right]:translate-x-[100%]",
"data-[pos=right]:pointer-events-none",
"data-[pos=left]:pointer-events-none",
].join(" ")}
>
{children}
</div>
);
};
/* ------ Exported ------ */
export const TabItem = (_: Omit<TabItemProps, "index">) => <></>;
export const Tab = <T extends ElementType = "div">({
wrapperComponent,
children,
...props
}: TabProps<T>) => {
const itemsWrapperRef = useRef<null | HTMLDivElement>(null);
const [selectedTab, setSelectedTab] = useState<string | undefined>(undefined);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const firstRenderRef = useRef(true);
const { tabHeaders, tabItems, idToIndex } = useMemo(() => {
/** Tab headers in form of an array */
const tabHeaders: SegmentedControlProps<"button">["items"] = [];
/** Tab contents in form of an array */
const tabItems: TabItemInternalProps[] = [];
/** Map of tabs id's to their indexes */
const idToIndex: Map<string, number> = new Map();
(
Children.toArray(children) as unknown as { props: TabItemProps }[]
).forEach(({ props: { children, id, title, icon } }, index) => {
tabItems.push({ id, index, children });
tabHeaders.push({ id, children: title, iconLeft: icon });
idToIndex.set(id, index);
if (index === 0) {
setSelectedTab(id);
}
});
return { tabHeaders, tabItems, idToIndex };
}, [children]);
const onSelect = useCallback(
(id: string) => {
const items = itemsWrapperRef.current;
const activeItemIndex = idToIndex.get(id);
if (items === null || typeof activeItemIndex === "undefined") return;
// Looping through the tab items
(items.childNodes as unknown as HTMLDivElement[]).forEach(
(child, index) => {
// Incase the current item is the active item
if (activeItemIndex === index) {
const { height } = child.getBoundingClientRect();
child.setAttribute("data-pos", "center");
items.style.setProperty("--height", height + "px");
return;
}
// Incase this item is before active item
if (activeItemIndex < index) {
child.setAttribute("data-pos", "right");
return;
}
// Incase this item is after active item
if (activeItemIndex > index) {
child.setAttribute("data-pos", "left");
return;
}
}
);
// Storing the active tab
setSelectedTab(id);
},
[idToIndex]
);
// Sets up a `ResizeObserver` to resize the tab, incase of resize in it's content
const setupResizeObserver = useCallback(() => {
if (resizeObserverRef.current !== null) return;
const { current: wrapper } = itemsWrapperRef;
resizeObserverRef.current = new ResizeObserver((entries) =>
entries.forEach((entry) => {
const isCentered = entry.target.getAttribute("data-pos") === "center";
if (isCentered) onSelect(entry.target.id.replace("tab-item-", ""));
})
);
const { current: observer } = resizeObserverRef;
if (wrapper !== null) {
wrapper.childNodes.forEach((node) =>
observer.observe(node as HTMLElement)
);
}
}, [onSelect]);
// Hydrating the component
useEffect(() => {
if (firstRenderRef.current) {
// @ts-ignore
onSelect(tabHeaders[0].id);
firstRenderRef.current = false;
setupResizeObserver();
}
}, [tabHeaders, onSelect, setupResizeObserver]);
return createElement(
wrapperComponent ? wrapperComponent : "div",
props,
<>
<SegmentedControl
selected={selectedTab}
onSelected={onSelect}
buttonComponents="button"
items={tabHeaders}
className="border w-full border-neutral-200 dark:border-neutral-800 [&_button]:grow p-1"
/>
<div
className={[
"mt-3",
"border",
"[transition:_height_300ms_ease-in-out]",
"rounded-3xl",
"border-neutral-200",
"dark:border-neutral-800",
"bg-neutral-100",
"dark:bg-neutral-900",
"overflow-hidden",
"relative h-[calc(var(--height,_auto)_+_theme(space.6)_+_2px)]",
].join(" ")}
ref={itemsWrapperRef}
>
{tabItems.map((props) => (
<TabItemInternal key={props.id} {...props} />
))}
</div>
</>
);
};