Skip to content

Commit 2611889

Browse files
authored
Merge pull request #3413 from ably/dx-1128/docs-components-interactive
DX-1128: localise interactive components (SegmentedControl, TabMenu)
2 parents 8f75f53 + 537ac59 commit 2611889

6 files changed

Lines changed: 343 additions & 45 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"class-variance-authority": "^0.7.1",
6262
"clsx": "^2.1.1",
6363
"dompurify": "^3.4.11",
64+
"es-toolkit": "^1.46.0",
6465
"fast-glob": "^3.3.3",
6566
"front-matter": "^4.0.2",
6667
"fs-extra": "^11.3.4",

src/components/Examples/ExamplesRenderer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { CodeEditor } from 'src/components/CodeEditor';
55
import { LanguageKey } from 'src/data/languages/types';
66
import { ExampleFiles, ExampleWithContent } from 'src/data/examples/types';
77
import { updateAblyConnectionKey } from 'src/utilities/update-ably-connection-keys';
8+
import Icon from 'src/components/Icon';
89
import { IconName } from 'src/components/Icon/types';
9-
import SegmentedControl from '@ably/ui/core/SegmentedControl';
10+
import SegmentedControl from 'src/components/ui/SegmentedControl';
1011
import dotGrid from './images/dot-grid.svg';
1112
import cn from 'src/utilities/cn';
1213
import { getRandomChannelName } from '../../utilities/get-random-channel-name';
@@ -164,7 +165,7 @@ const ExamplesRenderer = ({
164165
variant="subtle"
165166
active={activeLanguage === languageKey}
166167
rounded
167-
leftIcon={`icon-tech-${languageKey}` as IconName}
168+
leftIcon={<Icon name={`icon-tech-${languageKey}` as IconName} />}
168169
onClick={() => setActiveLanguage(languageKey as LanguageKey)}
169170
>
170171
{languageKey.charAt(0).toUpperCase() + languageKey.slice(1)}

src/components/Layout/Header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip';
66
import { throttle } from 'es-toolkit/compat';
77
import cn from 'src/utilities/cn';
88
import Icon from 'src/components/Icon';
9-
import TabMenu from '@ably/ui/core/TabMenu';
9+
import TabMenu from 'src/components/ui/TabMenu';
1010
import Logo from 'src/images/ably-logo.svg';
1111
import { track } from '@ably/ui/core/insights';
1212
import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from 'src/utilities/heights';
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React, { PropsWithChildren, ReactNode } from 'react';
2+
import cn from 'src/utilities/cn';
3+
import type { IconSize } from 'src/components/Icon/types';
4+
import IconSlot from './IconSlot';
5+
6+
export type SegmentedControlSize = 'md' | 'sm' | 'xs';
7+
8+
export type SegmentedControlProps = {
9+
className?: string;
10+
rounded?: boolean;
11+
leftIcon?: ReactNode;
12+
rightIcon?: ReactNode;
13+
active?: boolean;
14+
variant?: 'default' | 'subtle' | 'strong';
15+
size?: SegmentedControlSize;
16+
onClick?: () => void;
17+
disabled?: boolean;
18+
};
19+
20+
const SegmentedControl: React.FC<PropsWithChildren<SegmentedControlProps>> = ({
21+
className,
22+
rounded = false,
23+
leftIcon,
24+
rightIcon,
25+
active = false,
26+
variant = 'default',
27+
size = 'md',
28+
children,
29+
onClick,
30+
disabled,
31+
}) => {
32+
const colorStyles = {
33+
default: {
34+
active: 'bg-neutral-200 dark:bg-neutral-1100',
35+
inactive:
36+
'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',
37+
},
38+
subtle: {
39+
active: 'bg-neutral-000 dark:bg-neutral-1000',
40+
inactive:
41+
'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',
42+
},
43+
strong: {
44+
active: 'bg-neutral-1000 dark:bg-neutral-300',
45+
inactive:
46+
'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',
47+
},
48+
};
49+
50+
const contentColorStyles = {
51+
default: {
52+
active: 'text-neutral-1300 dark:text-neutral-000',
53+
inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000',
54+
},
55+
subtle: {
56+
active: 'text-neutral-1300 dark:text-neutral-000',
57+
inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000',
58+
},
59+
strong: {
60+
active: 'text-neutral-000 dark:text-neutral-1300',
61+
inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000',
62+
},
63+
};
64+
65+
const sizeStyles = {
66+
md: cn('h-12 p-3 gap-2.5', rounded && 'px-[1.125rem]'),
67+
sm: cn('h-10 p-[0.5625rem] gap-[0.5625rem]', rounded && 'px-3.5'),
68+
xs: cn('h-9 p-2 gap-2', rounded && 'px-3'),
69+
};
70+
71+
const textStyles = {
72+
md: 'ui-text-label2',
73+
sm: 'ui-text-label3',
74+
xs: 'ui-text-label4',
75+
};
76+
77+
const iconSizes: Record<SegmentedControlSize, IconSize> = {
78+
md: '23px',
79+
sm: '22px',
80+
xs: '20px',
81+
};
82+
83+
const activeKey = active ? 'active' : 'inactive';
84+
85+
const iconColor = contentColorStyles[variant][activeKey];
86+
87+
return (
88+
<div
89+
onClick={!disabled ? onClick : undefined}
90+
onKeyDown={(e) => {
91+
if ((e.key === 'Enter' || e.key === ' ') && !disabled && onClick) {
92+
e.preventDefault();
93+
onClick();
94+
}
95+
}}
96+
className={cn(
97+
'focus-base flex items-center justify-center cursor-pointer select-none transition-colors',
98+
colorStyles[variant][activeKey],
99+
contentColorStyles[variant][activeKey],
100+
sizeStyles[size],
101+
textStyles[size],
102+
disabled &&
103+
'cursor-not-allowed hover:bg-inherit dark:hover:bg-inherit active:bg-inherit dark:active:bg-inherit',
104+
rounded ? 'rounded-full' : 'rounded-lg',
105+
className,
106+
)}
107+
tabIndex={disabled ? -1 : 0}
108+
role="button"
109+
aria-pressed={active}
110+
aria-disabled={disabled}
111+
>
112+
{leftIcon && <IconSlot icon={leftIcon} size={iconSizes[size]} colorClass={iconColor} />}
113+
{children && (
114+
<span
115+
className={cn(
116+
'font-semibold transition-colors',
117+
contentColorStyles[variant][activeKey],
118+
disabled &&
119+
'text-gui-disabled-light dark:text-gui-disabled-dark hover:text-gui-disabled-light dark:hover:text-gui-disabled-dark',
120+
)}
121+
>
122+
{children}
123+
</span>
124+
)}
125+
{rightIcon && <IconSlot icon={rightIcon} size={iconSizes[size]} colorClass={iconColor} />}
126+
</div>
127+
);
128+
};
129+
130+
export default SegmentedControl;

src/components/ui/TabMenu.tsx

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import React, { ReactNode, useEffect } from 'react';
2+
import * as Tabs from '@radix-ui/react-tabs';
3+
import { throttle } from 'es-toolkit/compat';
4+
import cn from 'src/utilities/cn';
5+
6+
type TabTriggerContent = string | { label: string; disabled?: boolean } | ReactNode;
7+
8+
/**
9+
* Props for the TabMenu component.
10+
*/
11+
12+
export type TabMenuProps = {
13+
/**
14+
* An array of tabs, which can be either a string or an object with a label and an optional disabled state.
15+
*/
16+
tabs: TabTriggerContent[];
17+
18+
/**
19+
* An optional array of React nodes representing the content for each tab.
20+
*/
21+
contents?: ReactNode[];
22+
23+
/**
24+
* An optional callback function that is called when a tab is clicked, receiving the index of the clicked tab.
25+
*/
26+
tabOnClick?: (index: number) => void;
27+
28+
/**
29+
* An optional class name to apply to each tab.
30+
*/
31+
tabClassName?: string;
32+
33+
/**
34+
* An optional class name to apply to the Tabs.Root element.
35+
*/
36+
rootClassName?: string;
37+
38+
/**
39+
* An optional class name to apply to the Tabs.Content element.
40+
*/
41+
contentClassName?: string;
42+
43+
/**
44+
* Optional configuration options for the TabMenu.
45+
*/
46+
options?: {
47+
/**
48+
* The index of the tab that should be selected by default.
49+
*/
50+
defaultTabIndex?: number;
51+
52+
/**
53+
* Whether to show an underline below the selected tab.
54+
*/
55+
underline?: boolean;
56+
57+
/**
58+
* Whether to animate the transition between tabs.
59+
*/
60+
animated?: boolean;
61+
62+
/**
63+
* Whether the tab width should be flexible.
64+
*/
65+
flexibleTabWidth?: boolean;
66+
67+
/**
68+
* Whether the tab height should be flexible.
69+
*/
70+
flexibleTabHeight?: boolean;
71+
};
72+
};
73+
74+
const DEFAULT_TAILWIND_ANIMATION_DURATION = 150;
75+
76+
const TabMenu: React.FC<TabMenuProps> = ({
77+
tabs = [],
78+
contents = [],
79+
tabOnClick,
80+
tabClassName,
81+
rootClassName,
82+
contentClassName,
83+
options,
84+
}) => {
85+
const {
86+
defaultTabIndex = 0,
87+
underline = true,
88+
animated: animatedOption = true,
89+
flexibleTabWidth = false,
90+
flexibleTabHeight = false,
91+
} = options ?? {};
92+
93+
const listRef = React.useRef<HTMLDivElement>(null);
94+
const [animated, setAnimated] = React.useState(false);
95+
const [highlight, setHighlight] = React.useState({ offset: 0, width: 0 });
96+
97+
useEffect(() => {
98+
if (animatedOption && highlight.width > 0) {
99+
setTimeout(() => {
100+
setAnimated(true);
101+
}, DEFAULT_TAILWIND_ANIMATION_DURATION);
102+
}
103+
}, [animatedOption, highlight.width]);
104+
105+
const updateHighlightDimensions = (element: HTMLButtonElement) => {
106+
const { left: parentLeft } = listRef.current?.getBoundingClientRect() ?? {};
107+
const { left, width } = element.getBoundingClientRect() ?? {};
108+
109+
setHighlight({
110+
offset: (left ?? 0) - (parentLeft ?? 0),
111+
width: width ?? 0,
112+
});
113+
};
114+
115+
useEffect(() => {
116+
const handleResize = throttle(() => {
117+
const activeTabElement = listRef.current?.querySelector<HTMLButtonElement>(`[data-state="active"]`);
118+
119+
if (activeTabElement) {
120+
updateHighlightDimensions(activeTabElement);
121+
}
122+
}, 100);
123+
124+
handleResize();
125+
126+
window.addEventListener('resize', handleResize);
127+
128+
return () => {
129+
window.removeEventListener('resize', handleResize);
130+
};
131+
}, []);
132+
133+
const handleTabClick = (event: React.MouseEvent<HTMLButtonElement>, index: number) => {
134+
tabOnClick?.(index);
135+
updateHighlightDimensions(event.currentTarget as HTMLButtonElement);
136+
};
137+
138+
const tabTriggerContent = (tab: TabTriggerContent) => {
139+
if (!tab) {
140+
return null;
141+
}
142+
143+
if (React.isValidElement(tab) || typeof tab === 'string') {
144+
return tab;
145+
}
146+
147+
if (typeof tab === 'object' && 'label' in tab) {
148+
return tab.label;
149+
}
150+
151+
return null;
152+
};
153+
154+
return (
155+
<Tabs.Root defaultValue={`tab-${defaultTabIndex}`} className={cn({ 'h-full': flexibleTabHeight }, rootClassName)}>
156+
<Tabs.List
157+
ref={listRef}
158+
className={cn(
159+
'relative',
160+
{
161+
'flex border-b border-neutral-300 dark:border-neutral-1000': underline,
162+
},
163+
{ 'h-full': flexibleTabHeight },
164+
)}
165+
>
166+
{tabs.map(
167+
(tab, index) =>
168+
tab && (
169+
<Tabs.Trigger
170+
key={`tab-${index}`}
171+
className={cn(
172+
'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',
173+
{ 'flex-1': flexibleTabWidth },
174+
{ 'h-full': flexibleTabHeight },
175+
tabClassName,
176+
)}
177+
value={`tab-${index}`}
178+
onClick={(event) => handleTabClick(event, index)}
179+
disabled={typeof tab === 'object' && 'disabled' in tab ? tab.disabled : false}
180+
>
181+
{tabTriggerContent(tab)}
182+
</Tabs.Trigger>
183+
),
184+
)}
185+
<div
186+
className={cn('absolute bottom-0 bg-neutral-1300 dark:bg-neutral-000 h-[0.1875rem] w-6', {
187+
'transition-[transform,width]': animated,
188+
})}
189+
style={{
190+
transform: `translateX(${highlight.offset}px)`,
191+
width: `${highlight.width}px`,
192+
}}
193+
></div>
194+
</Tabs.List>
195+
{contents.map((content, index) => (
196+
<Tabs.Content key={`tab-${index}`} value={`tab-${index}`} className={contentClassName}>
197+
{content}
198+
</Tabs.Content>
199+
))}
200+
</Tabs.Root>
201+
);
202+
};
203+
204+
export default TabMenu;

0 commit comments

Comments
 (0)