Skip to content

Commit 94e7bcb

Browse files
committed
refactor(docs): localise interactive components (SegmentedControl, TabMenu) (DX-1128)
Vendor SegmentedControl and TabMenu locally, off @ably/ui/core. SegmentedControl's leftIcon/rightIcon props now take a ReactNode (Heroicon or Ably glyph passed directly, sized by the control) rather than an icon-name string, matching the glyphs-only local Icon and the rest of the localised components.
1 parent 9d02706 commit 94e7bcb

6 files changed

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