Skip to content

Commit d7d48f3

Browse files
authored
Merge pull request #2700 from innovaccer/develop
Develop
2 parents f2456a6 + 1cbd72a commit d7d48f3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+22003
-12214
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import * as React from 'react';
2+
import classNames from 'classnames';
3+
import { BaseProps, extractBaseProps } from '@/utils/types';
4+
import {
5+
SegmentedControlContext,
6+
SegmentedControlContextValue,
7+
SegmentedControlValue,
8+
SegmentedControlSize,
9+
} from './SegmentedControlContext';
10+
import { SegmentedControlItem, SegmentedControlItemProps } from './SegmentedControlItem';
11+
import { calculateIndicatorPosition, measureButtonWidths } from './utils';
12+
import styles from '@css/components/segmentedControl.module.css';
13+
14+
export type { SegmentedControlValue, SegmentedControlSize };
15+
16+
export interface SegmentedControlProps extends BaseProps {
17+
/**
18+
* Index of desired selected segment. By default is 0.
19+
*/
20+
activeIndex?: number;
21+
/**
22+
* Called with a new index when a new segment is selected by user.
23+
* @param index - Index of the selected segment
24+
* @param value - Value prop of the selected segment (if provided)
25+
*/
26+
onChange?: (index: number, value?: SegmentedControlValue) => void;
27+
/**
28+
* Size of the control.
29+
* @default "regular"
30+
*/
31+
size?: SegmentedControlSize;
32+
/**
33+
* Expands segments to fill available width.
34+
*/
35+
expanded?: boolean;
36+
/**
37+
* Maximum width of each segment.
38+
* @default "256px"
39+
*/
40+
maxWidth?: string | number;
41+
/**
42+
* Makes all segments equal width based on the largest content.
43+
* @default true
44+
*/
45+
isEqualWidth?: boolean;
46+
/**
47+
* Disables the entire control and all segments.
48+
*/
49+
disabled?: boolean;
50+
/**
51+
* Child segments (SegmentedControl.Item components)
52+
*/
53+
children: React.ReactElement<SegmentedControlItemProps> | React.ReactElement<SegmentedControlItemProps>[];
54+
}
55+
56+
export const SegmentedControl = (props: SegmentedControlProps) => {
57+
const {
58+
activeIndex,
59+
onChange,
60+
size = 'regular',
61+
expanded = false,
62+
maxWidth = '256px',
63+
isEqualWidth = true,
64+
disabled = false,
65+
className,
66+
children,
67+
} = props;
68+
69+
const baseProps = extractBaseProps(props);
70+
71+
const childrenArray = React.Children.toArray(children) as React.ReactElement<SegmentedControlItemProps>[];
72+
const validChildren = childrenArray.filter((child) => React.isValidElement(child));
73+
const totalChildren = validChildren.length;
74+
75+
const [internalIndex, setInternalIndex] = React.useState<number>(
76+
activeIndex !== undefined && activeIndex < totalChildren ? activeIndex : 0
77+
);
78+
79+
React.useEffect(() => {
80+
if (activeIndex !== undefined && activeIndex < totalChildren) {
81+
setInternalIndex(activeIndex);
82+
}
83+
}, [activeIndex, totalChildren]);
84+
85+
const selectedIndex =
86+
activeIndex !== undefined
87+
? Math.max(0, Math.min(activeIndex, totalChildren - 1))
88+
: Math.max(0, Math.min(internalIndex, totalChildren - 1));
89+
90+
const containerRef = React.useRef<HTMLDivElement | null>(null);
91+
const indicatorRef = React.useRef<HTMLDivElement | null>(null);
92+
const [indicatorStyle, setIndicatorStyle] = React.useState<{
93+
left: number;
94+
width: number;
95+
top: number;
96+
height: number;
97+
}>({ left: 0, width: 0, top: 0, height: 0 });
98+
const buttonRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
99+
const dividerRefs = React.useRef<Array<HTMLSpanElement | null>>([]);
100+
const [equalWidth, setEqualWidth] = React.useState<number | null>(null);
101+
const [isConstrained, setIsConstrained] = React.useState<boolean>(false);
102+
103+
React.useEffect(() => {
104+
buttonRefs.current = buttonRefs.current.slice(0, totalChildren);
105+
dividerRefs.current = dividerRefs.current.slice(0, Math.max(0, totalChildren - 1));
106+
}, [totalChildren]);
107+
108+
React.useLayoutEffect(() => {
109+
if (expanded || !isEqualWidth) {
110+
setEqualWidth(null);
111+
setIsConstrained(false);
112+
return;
113+
}
114+
115+
// Use nested RAF to ensure DOM is fully updated before measuring
116+
requestAnimationFrame(() => {
117+
requestAnimationFrame(() => {
118+
const buttons = buttonRefs.current.filter(Boolean) as HTMLButtonElement[];
119+
const result = measureButtonWidths({ buttons, maxWidth });
120+
setEqualWidth(result.equalWidth);
121+
setIsConstrained(result.isConstrained);
122+
});
123+
});
124+
}, [expanded, isEqualWidth, totalChildren, children, maxWidth]);
125+
126+
const isInitialRender = React.useRef(true);
127+
128+
// Use useLayoutEffect to measure and update indicator synchronously after layout
129+
React.useLayoutEffect(() => {
130+
const selectedButton = buttonRefs.current[selectedIndex];
131+
const container = containerRef.current;
132+
const indicator = indicatorRef.current;
133+
134+
if (!selectedButton || !container || !indicator) return;
135+
136+
// Disable transition on first render to avoid animation from initial position
137+
if (isInitialRender.current) {
138+
indicator.style.transition = 'none';
139+
}
140+
141+
const dimensions = calculateIndicatorPosition({
142+
selectedButton,
143+
container,
144+
selectedIndex,
145+
totalChildren,
146+
dividerRefs: dividerRefs.current,
147+
});
148+
149+
setIndicatorStyle(dimensions);
150+
if (isInitialRender.current) {
151+
requestAnimationFrame(() => {
152+
requestAnimationFrame(() => {
153+
if (indicator) {
154+
indicator.style.transition = '';
155+
}
156+
isInitialRender.current = false;
157+
});
158+
});
159+
}
160+
}, [selectedIndex, size, expanded, isEqualWidth, equalWidth, maxWidth, totalChildren]);
161+
162+
React.useEffect(() => {
163+
const handleResize = () => {
164+
const selectedButton = buttonRefs.current[selectedIndex];
165+
const container = containerRef.current;
166+
const indicator = indicatorRef.current;
167+
168+
if (!selectedButton || !container || !indicator) return;
169+
170+
const dimensions = calculateIndicatorPosition({
171+
selectedButton,
172+
container,
173+
selectedIndex,
174+
totalChildren,
175+
dividerRefs: dividerRefs.current,
176+
});
177+
178+
setIndicatorStyle(dimensions);
179+
};
180+
181+
window.addEventListener('resize', handleResize);
182+
return () => window.removeEventListener('resize', handleResize);
183+
}, [selectedIndex, totalChildren]);
184+
185+
const emitChange = (index: number, value?: SegmentedControlValue) => {
186+
if (disabled || index < 0 || index >= totalChildren) return;
187+
const child = validChildren[index];
188+
if (child?.props.disabled) return;
189+
190+
let targetIndex = index;
191+
let targetValue = value;
192+
193+
if (totalChildren === 2 && selectedIndex === index) {
194+
const otherIndex = index === 0 ? 1 : 0;
195+
if (validChildren[otherIndex]?.props.disabled) return;
196+
targetIndex = otherIndex;
197+
targetValue = validChildren[otherIndex]?.props.value;
198+
}
199+
200+
if (activeIndex === undefined) {
201+
setInternalIndex(targetIndex);
202+
}
203+
onChange?.(targetIndex, targetValue);
204+
};
205+
206+
const controlClass = classNames(
207+
styles['SegmentedControl'],
208+
styles[`SegmentedControl--${size}`],
209+
{
210+
[styles['SegmentedControl--expanded']]: expanded,
211+
[styles['SegmentedControl--equalWidth']]: !expanded && isEqualWidth,
212+
[styles['SegmentedControl--disabled']]: disabled,
213+
[styles['SegmentedControl--twoSegments']]: totalChildren === 2,
214+
},
215+
className
216+
);
217+
218+
const containerStyle: React.CSSProperties & { '--segment-max-width'?: string; '--segment-equal-width'?: string } = {
219+
maxWidth: '100%',
220+
};
221+
if (!expanded) {
222+
if (maxWidth) {
223+
containerStyle['--segment-max-width'] = typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
224+
}
225+
if (isEqualWidth && equalWidth) {
226+
containerStyle['--segment-equal-width'] = `${equalWidth}px`;
227+
}
228+
}
229+
230+
if (!validChildren.length) return null;
231+
232+
return (
233+
<div
234+
className={controlClass}
235+
style={containerStyle}
236+
ref={containerRef}
237+
data-test="DesignSystem-SegmentedControl"
238+
{...baseProps}
239+
>
240+
<div
241+
ref={indicatorRef}
242+
className={styles['SegmentedControl-indicator']}
243+
style={{
244+
transform: `translateX(${indicatorStyle.left}px)`,
245+
width: `${indicatorStyle.width}px`,
246+
top: `${indicatorStyle.top}px`,
247+
height: `${indicatorStyle.height}px`,
248+
}}
249+
/>
250+
{validChildren.map((child, index) => {
251+
const contextValue: SegmentedControlContextValue = {
252+
size,
253+
selectedIndex,
254+
onSelect: emitChange,
255+
index,
256+
registerButtonRef: (i, node) => {
257+
buttonRefs.current[i] = node;
258+
},
259+
expanded,
260+
isEqualWidth: !expanded && isEqualWidth,
261+
disabled,
262+
isTwoSegments: totalChildren === 2,
263+
isConstrained: !expanded && isEqualWidth && isConstrained,
264+
};
265+
266+
const segmentNode = (
267+
<SegmentedControlContext.Provider key={child.key ?? index} value={contextValue}>
268+
{React.cloneElement(child, {
269+
disabled: child.props.disabled,
270+
} as Partial<SegmentedControlItemProps>)}
271+
</SegmentedControlContext.Provider>
272+
);
273+
274+
if (index === validChildren.length - 1) {
275+
return segmentNode;
276+
}
277+
278+
const isDividerHidden = index === selectedIndex || index === selectedIndex - 1;
279+
const dividerClass = classNames(
280+
styles['SegmentedControl-divider'],
281+
styles[`SegmentedControl-divider--${size}`],
282+
{
283+
[styles['SegmentedControl-divider--hidden']]: isDividerHidden,
284+
}
285+
);
286+
287+
return (
288+
<React.Fragment key={child.key ?? index}>
289+
{segmentNode}
290+
<span
291+
ref={(node) => {
292+
dividerRefs.current[index] = node;
293+
}}
294+
className={dividerClass}
295+
/>
296+
</React.Fragment>
297+
);
298+
})}
299+
</div>
300+
);
301+
};
302+
303+
SegmentedControl.displayName = 'SegmentedControl';
304+
305+
SegmentedControl.defaultProps = {
306+
size: 'regular',
307+
expanded: false,
308+
maxWidth: '256px',
309+
isEqualWidth: true,
310+
disabled: false,
311+
};
312+
313+
SegmentedControl.Item = SegmentedControlItem;
314+
315+
export default SegmentedControl;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from 'react';
2+
3+
export type SegmentedControlValue = React.ReactText;
4+
export type SegmentedControlSize = 'small' | 'regular' | 'large';
5+
6+
export interface SegmentedControlContextValue {
7+
size: SegmentedControlSize;
8+
selectedIndex: number;
9+
onSelect: (index: number, value?: SegmentedControlValue) => void;
10+
index: number;
11+
registerButtonRef?: (index: number, node: HTMLButtonElement | null) => void;
12+
expanded?: boolean;
13+
isEqualWidth?: boolean;
14+
disabled?: boolean;
15+
isTwoSegments?: boolean;
16+
isConstrained?: boolean;
17+
}
18+
19+
export const SegmentedControlContext = React.createContext<SegmentedControlContextValue | null>(null);

0 commit comments

Comments
 (0)