Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.0",
"es-toolkit": "^1.44.0",
"fast-glob": "^3.3.3",
"front-matter": "^4.0.2",
"fs-extra": "^11.3.4",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Examples/ExamplesRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { LanguageKey } from 'src/data/languages/types';
import { ExampleFiles, ExampleWithContent } from 'src/data/examples/types';
import { updateAblyConnectionKey } from 'src/utilities/update-ably-connection-keys';
import { IconName } from 'src/components/Icon/types';
import SegmentedControl from '@ably/ui/core/SegmentedControl';
import SegmentedControl from 'src/components/ui/SegmentedControl';
import dotGrid from './images/dot-grid.svg';
import cn from 'src/utilities/cn';
import { getRandomChannelName } from '../../utilities/get-random-channel-name';
Expand Down
2 changes: 1 addition & 1 deletion src/components/Layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip';
import { throttle } from 'es-toolkit/compat';
import cn from 'src/utilities/cn';
import Icon from 'src/components/Icon';
import TabMenu from '@ably/ui/core/TabMenu';
import TabMenu from 'src/components/ui/TabMenu';
import Logo from 'src/images/ably-logo.svg';
import { track } from '@ably/ui/core/insights';
import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from 'src/utilities/heights';
Expand Down
143 changes: 143 additions & 0 deletions src/components/ui/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React, { PropsWithChildren } from 'react';
import cn from 'src/utilities/cn';
import Icon from 'src/components/Icon';
import type { IconName, IconSize } from 'src/components/Icon/types';
import { ColorClass } from './colors';

export type SegmentedControlSize = 'md' | 'sm' | 'xs';

export type SegmentedControlProps = {
className?: string;
rounded?: boolean;
leftIcon?: IconName;
rightIcon?: IconName;
active?: boolean;
variant?: 'default' | 'subtle' | 'strong';
size?: SegmentedControlSize;
onClick?: () => void;
disabled?: boolean;
};

const SegmentedControl: React.FC<PropsWithChildren<SegmentedControlProps>> = ({
className,
rounded = false,
leftIcon,
rightIcon,
active = false,
variant = 'default',
size = 'md',
children,
onClick,
disabled,
}) => {
const colorStyles = {
default: {
active: 'bg-neutral-200 dark:bg-neutral-1100',
inactive:
'bg-neutral-000 dark:bg-neutral-1300 hover:bg-neutral-100 dark:hover:bg-neutral-1200 active:bg-neutral-100 dark:active:bg-neutral-1200',
},
subtle: {
active: 'bg-neutral-000 dark:bg-neutral-1000',
inactive:
'bg-neutral-100 dark:bg-neutral-1200 hover:bg-neutral-200 dark:hover:bg-neutral-1100 active:bg-neutral-200 dark:active:bg-neutral-1100',
},
strong: {
active: 'bg-neutral-1000 dark:bg-neutral-300',
inactive:
'bg-neutral-100 dark:bg-neutral-1200 hover:bg-neutral-200 dark:hover:bg-neutral-1100 active:bg-neutral-200 dark:active:bg-neutral-1100',
},
};

const contentColorStyles = {
default: {
active: 'text-neutral-1300 dark:text-neutral-000',
inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000',
},
subtle: {
active: 'text-neutral-1300 dark:text-neutral-000',
inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000',
},
strong: {
active: 'text-neutral-000 dark:text-neutral-1300',
inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000',
},
};

const sizeStyles = {
md: cn('h-12 p-3 gap-2.5', rounded && 'px-[1.125rem]'),
sm: cn('h-10 p-[0.5625rem] gap-[0.5625rem]', rounded && 'px-3.5'),
xs: cn('h-9 p-2 gap-2', rounded && 'px-3'),
};

const textStyles = {
md: 'ui-text-label2',
sm: 'ui-text-label3',
xs: 'ui-text-label4',
};

const iconSizes: Record<SegmentedControlSize, IconSize> = {
md: '23px',
sm: '22px',
xs: '20px',
};

const activeKey = active ? 'active' : 'inactive';

return (
<div
onClick={!disabled ? onClick : undefined}
onKeyDown={(e) => {
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 && (
<Icon
name={leftIcon}
size={iconSizes[size]}
aria-hidden="true"
color={contentColorStyles[variant][activeKey] as ColorClass}
/>
)}
{children && (
<span
className={cn(
'font-semibold transition-colors',
contentColorStyles[variant][activeKey],
disabled &&
'text-gui-disabled-light dark:text-gui-disabled-dark hover:text-gui-disabled-light dark:hover:text-gui-disabled-dark',
)}
>
{children}
</span>
)}
{rightIcon && (
<Icon
name={rightIcon}
size={iconSizes[size]}
aria-hidden="true"
color={contentColorStyles[variant][activeKey] as ColorClass}
/>
)}
</div>
);
};

export default SegmentedControl;
204 changes: 204 additions & 0 deletions src/components/ui/TabMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<TabMenuProps> = ({
tabs = [],
contents = [],
tabOnClick,
tabClassName,
rootClassName,
contentClassName,
options,
}) => {
const {
defaultTabIndex = 0,
underline = true,
animated: animatedOption = true,
flexibleTabWidth = false,
flexibleTabHeight = false,
} = options ?? {};

const listRef = React.useRef<HTMLDivElement>(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<HTMLButtonElement>(`[data-state="active"]`);

if (activeTabElement) {
updateHighlightDimensions(activeTabElement);
}
}, 100);

handleResize();

window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

const handleTabClick = (event: React.MouseEvent<HTMLButtonElement>, 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.Root defaultValue={`tab-${defaultTabIndex}`} className={cn({ 'h-full': flexibleTabHeight }, rootClassName)}>
<Tabs.List
ref={listRef}
className={cn(
'relative',
{
'flex border-b border-neutral-300 dark:border-neutral-1000': underline,
},
{ 'h-full': flexibleTabHeight },
)}
>
{tabs.map(
(tab, index) =>
tab && (
<Tabs.Trigger
key={`tab-${index}`}
className={cn(
'lg:px-6 md:px-5 px-4 py-4 ui-text-label1 font-bold data-[state=active]:text-neutral-1300 text-neutral-1000 dark:data-[state=active]:text-neutral-000 dark:text-neutral-300 focus:outline-none focus-visible:outline-gui-focus transition-colors hover:text-neutral-1300 dark:hover:text-neutral-000 active:text-neutral-900 dark:active:text-neutral-400 disabled:text-gui-disabled-light dark:disabled:text-gui-disabled-dark disabled:cursor-not-allowed',
{ 'flex-1': flexibleTabWidth },
{ 'h-full': flexibleTabHeight },
tabClassName,
)}
value={`tab-${index}`}
onClick={(event) => handleTabClick(event, index)}
disabled={typeof tab === 'object' && 'disabled' in tab ? tab.disabled : false}
>
{tabTriggerContent(tab)}
</Tabs.Trigger>
),
)}
<div
className={cn('absolute bottom-0 bg-neutral-1300 dark:bg-neutral-000 h-[0.1875rem] w-6', {
'transition-[transform,width]': animated,
})}
style={{
transform: `translateX(${highlight.offset}px)`,
width: `${highlight.width}px`,
}}
></div>
</Tabs.List>
{contents.map((content, index) => (
<Tabs.Content key={`tab-${index}`} value={`tab-${index}`} className={contentClassName}>
{content}
</Tabs.Content>
))}
</Tabs.Root>
);
};

export default TabMenu;