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>
    </>
  );
};

Loading...