Skip to content

Commit b7b6074

Browse files
committed
refactor(docs): localise interactive components (SegmentedControl, TabMenu) (DX-1128)
1 parent 88c9b9f commit b7b6074

6 files changed

Lines changed: 355 additions & 10 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.44.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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ 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';
88
import { IconName } from 'src/components/Icon/types';
9-
import SegmentedControl from '@ably/ui/core/SegmentedControl';
9+
import SegmentedControl from 'src/components/ui/SegmentedControl';
1010
import dotGrid from './images/dot-grid.svg';
1111
import cn from 'src/utilities/cn';
1212
import { getRandomChannelName } from '../../utilities/get-random-channel-name';

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