= {
+ md: '23px',
+ sm: '22px',
+ xs: '20px',
+ };
+
+ const activeKey = active ? 'active' : 'inactive';
+
+ const iconColor = contentColorStyles[variant][activeKey];
+
+ return (
+ {
+ if ((e.key === 'Enter' || e.key === ' ') && !disabled && onClick) {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ className={cn(
+ 'focus-base flex items-center justify-center cursor-pointer select-none transition-colors',
+ colorStyles[variant][activeKey],
+ contentColorStyles[variant][activeKey],
+ sizeStyles[size],
+ textStyles[size],
+ disabled &&
+ 'cursor-not-allowed hover:bg-inherit dark:hover:bg-inherit active:bg-inherit dark:active:bg-inherit',
+ rounded ? 'rounded-full' : 'rounded-lg',
+ className,
+ )}
+ tabIndex={disabled ? -1 : 0}
+ role="button"
+ aria-pressed={active}
+ aria-disabled={disabled}
+ >
+ {leftIcon && }
+ {children && (
+
+ {children}
+
+ )}
+ {rightIcon && }
+
+ );
+};
+
+export default SegmentedControl;
diff --git a/src/components/ui/TabMenu.tsx b/src/components/ui/TabMenu.tsx
new file mode 100644
index 0000000000..f57f5de86e
--- /dev/null
+++ b/src/components/ui/TabMenu.tsx
@@ -0,0 +1,204 @@
+import React, { ReactNode, useEffect } from 'react';
+import * as Tabs from '@radix-ui/react-tabs';
+import { throttle } from 'es-toolkit/compat';
+import cn from 'src/utilities/cn';
+
+type TabTriggerContent = string | { label: string; disabled?: boolean } | ReactNode;
+
+/**
+ * Props for the TabMenu component.
+ */
+
+export type TabMenuProps = {
+ /**
+ * An array of tabs, which can be either a string or an object with a label and an optional disabled state.
+ */
+ tabs: TabTriggerContent[];
+
+ /**
+ * An optional array of React nodes representing the content for each tab.
+ */
+ contents?: ReactNode[];
+
+ /**
+ * An optional callback function that is called when a tab is clicked, receiving the index of the clicked tab.
+ */
+ tabOnClick?: (index: number) => void;
+
+ /**
+ * An optional class name to apply to each tab.
+ */
+ tabClassName?: string;
+
+ /**
+ * An optional class name to apply to the Tabs.Root element.
+ */
+ rootClassName?: string;
+
+ /**
+ * An optional class name to apply to the Tabs.Content element.
+ */
+ contentClassName?: string;
+
+ /**
+ * Optional configuration options for the TabMenu.
+ */
+ options?: {
+ /**
+ * The index of the tab that should be selected by default.
+ */
+ defaultTabIndex?: number;
+
+ /**
+ * Whether to show an underline below the selected tab.
+ */
+ underline?: boolean;
+
+ /**
+ * Whether to animate the transition between tabs.
+ */
+ animated?: boolean;
+
+ /**
+ * Whether the tab width should be flexible.
+ */
+ flexibleTabWidth?: boolean;
+
+ /**
+ * Whether the tab height should be flexible.
+ */
+ flexibleTabHeight?: boolean;
+ };
+};
+
+const DEFAULT_TAILWIND_ANIMATION_DURATION = 150;
+
+const TabMenu: React.FC = ({
+ tabs = [],
+ contents = [],
+ tabOnClick,
+ tabClassName,
+ rootClassName,
+ contentClassName,
+ options,
+}) => {
+ const {
+ defaultTabIndex = 0,
+ underline = true,
+ animated: animatedOption = true,
+ flexibleTabWidth = false,
+ flexibleTabHeight = false,
+ } = options ?? {};
+
+ const listRef = React.useRef(null);
+ const [animated, setAnimated] = React.useState(false);
+ const [highlight, setHighlight] = React.useState({ offset: 0, width: 0 });
+
+ useEffect(() => {
+ if (animatedOption && highlight.width > 0) {
+ setTimeout(() => {
+ setAnimated(true);
+ }, DEFAULT_TAILWIND_ANIMATION_DURATION);
+ }
+ }, [animatedOption, highlight.width]);
+
+ const updateHighlightDimensions = (element: HTMLButtonElement) => {
+ const { left: parentLeft } = listRef.current?.getBoundingClientRect() ?? {};
+ const { left, width } = element.getBoundingClientRect() ?? {};
+
+ setHighlight({
+ offset: (left ?? 0) - (parentLeft ?? 0),
+ width: width ?? 0,
+ });
+ };
+
+ useEffect(() => {
+ const handleResize = throttle(() => {
+ const activeTabElement = listRef.current?.querySelector(`[data-state="active"]`);
+
+ if (activeTabElement) {
+ updateHighlightDimensions(activeTabElement);
+ }
+ }, 100);
+
+ handleResize();
+
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, []);
+
+ const handleTabClick = (event: React.MouseEvent, index: number) => {
+ tabOnClick?.(index);
+ updateHighlightDimensions(event.currentTarget as HTMLButtonElement);
+ };
+
+ const tabTriggerContent = (tab: TabTriggerContent) => {
+ if (!tab) {
+ return null;
+ }
+
+ if (React.isValidElement(tab) || typeof tab === 'string') {
+ return tab;
+ }
+
+ if (typeof tab === 'object' && 'label' in tab) {
+ return tab.label;
+ }
+
+ return null;
+ };
+
+ return (
+
+
+ {tabs.map(
+ (tab, index) =>
+ tab && (
+ handleTabClick(event, index)}
+ disabled={typeof tab === 'object' && 'disabled' in tab ? tab.disabled : false}
+ >
+ {tabTriggerContent(tab)}
+
+ ),
+ )}
+
+
+ {contents.map((content, index) => (
+
+ {content}
+
+ ))}
+
+ );
+};
+
+export default TabMenu;
diff --git a/yarn.lock b/yarn.lock
index f65f611d71..ff8645bddb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13813,7 +13813,7 @@ react-helmet@^6.1.0:
react-fast-compare "^3.1.1"
react-side-effect "^2.1.0"
-"react-is-18@npm:react-is@^18.3.1":
+"react-is-18@npm:react-is@^18.3.1", "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
@@ -13823,11 +13823,6 @@ react-helmet@^6.1.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2"
integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==
-"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.3.1:
- version "18.3.1"
- resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
- integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
-
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -15022,16 +15017,7 @@ string-similarity@^1.2.2:
lodash.map "^4.6.0"
lodash.maxby "^4.6.0"
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -15141,7 +15127,7 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -15162,13 +15148,6 @@ strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
strip-ansi@^7.0.1:
version "7.1.2"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba"
@@ -15340,14 +15319,6 @@ swr@^2.4.0:
dequal "^2.0.3"
use-sync-external-store "^1.6.0"
-swr@^2.4.0:
- version "2.4.1"
- resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.1.tgz#c9e48abff6bf4b04846342e2f1f6be108a078cf6"
- integrity sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==
- dependencies:
- dequal "^2.0.3"
- use-sync-external-store "^1.6.0"
-
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@@ -16631,7 +16602,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -16649,15 +16620,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-wrap-ansi@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"