Skip to content

Commit bc3e966

Browse files
committed
feat(SegmentedControl): allow for passing stricter type for options
1 parent 6bea33f commit bc3e966

File tree

15 files changed

+242
-225
lines changed

15 files changed

+242
-225
lines changed

packages/core/src/components/icon/icon.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ export interface DefaultIconProps extends IntentProps, Props, DefaultSVGIconProp
9494
}
9595

9696
/**
97-
* Generic icon component type. This is essentially a type hack required to make forwardRef work with generic
98-
* components. Note that this slows down TypeScript compilation, but it better than the alternative of globally
97+
* Generic component type. This is essentially a type hack required to make forwardRef work with generic
98+
* components. Note that this slows down TypeScript compilation, but is better than the alternative of globally
9999
* augmenting "@types/react".
100100
*
101101
* @see https://stackoverflow.com/a/73795494/7406866

packages/core/src/components/segmented-control/segmented-control.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ Options are specified as `OptionProps` objects, just like [RadioGroup](#core/com
4040
/>
4141
```
4242

43+
Options type is `string` by default, but can be made stricter, i.e.
44+
45+
```tsx
46+
enum OptionType
47+
48+
<SegmentedControl<OptionType>
49+
options={[
50+
{ value: OptionType.VALUE_1 },
51+
{ value: OptionType.VALUE_2 },
52+
]}
53+
/>
54+
```
55+
4356
@## Props interface
4457

4558
@interface SegmentedControlProps

packages/core/src/components/segmented-control/segmentedControl.tsx

Lines changed: 145 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;
3636
/**
3737
* SegmentedControl component props.
3838
*/
39-
export interface SegmentedControlProps
39+
export interface SegmentedControlProps<T extends string = string>
4040
extends Props,
41-
ControlledValueProps<string>,
41+
ControlledValueProps<T>,
4242
React.RefAttributes<HTMLDivElement> {
4343
/**
4444
* Whether the control should take up the full width of its container.
@@ -68,7 +68,7 @@ export interface SegmentedControlProps
6868
/**
6969
* List of available options.
7070
*/
71-
options: Array<OptionProps<string>>;
71+
options: Array<OptionProps<T>>;
7272

7373
/**
7474
* Aria role for the overall component. Child buttons get appropriate roles.
@@ -95,137 +95,165 @@ export interface SegmentedControlProps
9595
small?: boolean;
9696
}
9797

98+
/**
99+
* Generic component type. This is essentially a type hack required to make forwardRef work with generic
100+
* components. Note that this slows down TypeScript compilation, but is better than the alternative of globally
101+
* augmenting "@types/react".
102+
*
103+
* @see https://stackoverflow.com/a/73795494/7406866
104+
*/
105+
export interface SegmentedControlComponent extends React.FC<SegmentedControlProps> {
106+
/**
107+
* ReturnType here preserves type compatability with React 16 while we migrate to React 18.
108+
* see: https://github.com/palantir/blueprint/pull/7142/files#r1915691062
109+
*/
110+
// TODO(React 18): Replace return type with `React.ReactNode` once we drop support for React 16.
111+
<T extends string>(props: SegmentedControlProps<T>): ReturnType<React.FC<SegmentedControlProps<T>>> | null;
112+
}
113+
98114
/**
99115
* Segmented control component.
100116
*
101117
* @see https://blueprintjs.com/docs/#core/components/segmented-control
102118
*/
103-
export const SegmentedControl: React.FC<SegmentedControlProps> = React.forwardRef((props, ref) => {
104-
const {
105-
className,
106-
defaultValue,
107-
fill,
108-
inline,
109-
intent = Intent.NONE,
110-
// eslint-disable-next-line @typescript-eslint/no-deprecated
111-
large,
112-
onValueChange,
113-
options,
114-
role = "radiogroup",
115-
size = "medium",
116-
// eslint-disable-next-line @typescript-eslint/no-deprecated
117-
small,
118-
value: controlledValue,
119-
...htmlProps
120-
} = props;
121-
122-
const [localValue, setLocalValue] = React.useState<string | undefined>(defaultValue);
123-
const selectedValue = controlledValue ?? localValue;
124-
125-
const outerRef = React.useRef<HTMLDivElement>(null);
126-
127-
useValidateProps(() => {
128-
logDeprecatedSizeWarning("SegmentedControl", { large, small });
129-
}, [large, small]);
130-
131-
const handleOptionClick = React.useCallback(
132-
(newSelectedValue: string, targetElement: HTMLElement) => {
133-
setLocalValue(newSelectedValue);
134-
onValueChange?.(newSelectedValue, targetElement);
135-
},
136-
[onValueChange],
137-
);
119+
export const SegmentedControl: SegmentedControlComponent = React.forwardRef(
120+
<T extends string>(props: SegmentedControlProps<T>, ref: React.ForwardedRef<HTMLDivElement>) => {
121+
const {
122+
className,
123+
defaultValue,
124+
fill,
125+
inline,
126+
intent = Intent.NONE,
127+
// eslint-disable-next-line @typescript-eslint/no-deprecated
128+
large,
129+
onValueChange,
130+
options,
131+
role = "radiogroup",
132+
size = "medium",
133+
// eslint-disable-next-line @typescript-eslint/no-deprecated
134+
small,
135+
value: controlledValue,
136+
...htmlProps
137+
} = props;
138138

139-
const handleKeyDown = React.useCallback(
140-
(e: React.KeyboardEvent<HTMLDivElement>) => {
141-
if (role === "radiogroup") {
142-
// in a `radiogroup`, arrow keys select next item, not tab key.
143-
const direction = Utils.getArrowKeyDirection(e, ["ArrowLeft", "ArrowUp"], ["ArrowRight", "ArrowDown"]);
144-
const outerElement = outerRef.current;
145-
if (direction === undefined || !outerElement) return;
146-
147-
const focusedElement = Utils.getActiveElement(outerElement)?.closest<HTMLButtonElement>("button");
148-
if (!focusedElement) return;
149-
150-
// must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
151-
const enabledOptionElements = Array.from(
152-
outerElement.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"),
153-
);
154-
const focusedIndex = enabledOptionElements.indexOf(focusedElement);
155-
if (focusedIndex < 0) return;
156-
157-
e.preventDefault();
158-
// auto-wrapping at 0 and `length`
159-
const newIndex =
160-
(focusedIndex + direction + enabledOptionElements.length) % enabledOptionElements.length;
161-
const newOption = enabledOptionElements[newIndex];
162-
newOption.click();
163-
newOption.focus();
164-
}
165-
},
166-
[outerRef, role],
167-
);
139+
const [localValue, setLocalValue] = React.useState<T | undefined>(defaultValue);
140+
const selectedValue = controlledValue ?? localValue;
168141

169-
const classes = classNames(Classes.SEGMENTED_CONTROL, className, {
170-
[Classes.FILL]: fill,
171-
[Classes.INLINE]: inline,
172-
});
142+
const outerRef = React.useRef<HTMLDivElement>(null);
173143

174-
const isAnySelected = options.some(option => selectedValue === option.value);
144+
useValidateProps(() => {
145+
logDeprecatedSizeWarning("SegmentedControl", { large, small });
146+
}, [large, small]);
175147

176-
return (
177-
<div
178-
{...removeNonHTMLProps(htmlProps)}
179-
role={role}
180-
onKeyDown={handleKeyDown}
181-
className={classes}
182-
ref={mergeRefs(ref, outerRef)}
183-
>
184-
{options.map((option, index) => {
185-
const isSelected = selectedValue === option.value;
186-
return (
187-
<SegmentedControlOption
188-
{...option}
189-
intent={intent}
190-
isSelected={isSelected}
191-
key={option.value}
192-
// eslint-disable-next-line @typescript-eslint/no-deprecated
193-
large={large}
194-
onClick={handleOptionClick}
195-
size={size}
196-
// eslint-disable-next-line @typescript-eslint/no-deprecated
197-
small={small}
198-
{...(role === "radiogroup"
199-
? {
200-
"aria-checked": isSelected,
201-
role: "radio",
202-
// "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
203-
// `!isAnySelected` accounts for case where no value is currently selected
204-
// (passed value/defaultValue is not one of the values of the passed options.)
205-
// In this case, set first item to be tabbable even though it's unselected.
206-
tabIndex: isSelected || (index === 0 && !isAnySelected) ? 0 : -1,
207-
}
208-
: {
209-
"aria-pressed": isSelected,
210-
})}
211-
/>
212-
);
213-
})}
214-
</div>
215-
);
216-
});
148+
const handleOptionClick = React.useCallback(
149+
(newSelectedValue: T, targetElement: HTMLElement) => {
150+
setLocalValue(newSelectedValue);
151+
onValueChange?.(newSelectedValue, targetElement);
152+
},
153+
[onValueChange],
154+
);
155+
156+
const handleKeyDown = React.useCallback(
157+
(e: React.KeyboardEvent<HTMLDivElement>) => {
158+
if (role === "radiogroup") {
159+
// in a `radiogroup`, arrow keys select next item, not tab key.
160+
const direction = Utils.getArrowKeyDirection(
161+
e,
162+
["ArrowLeft", "ArrowUp"],
163+
["ArrowRight", "ArrowDown"],
164+
);
165+
const outerElement = outerRef.current;
166+
if (direction === undefined || !outerElement) return;
167+
168+
const focusedElement = Utils.getActiveElement(outerElement)?.closest<HTMLButtonElement>("button");
169+
if (!focusedElement) return;
170+
171+
// must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
172+
const enabledOptionElements = Array.from(
173+
outerElement.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"),
174+
);
175+
const focusedIndex = enabledOptionElements.indexOf(focusedElement);
176+
if (focusedIndex < 0) return;
177+
178+
e.preventDefault();
179+
// auto-wrapping at 0 and `length`
180+
const newIndex =
181+
(focusedIndex + direction + enabledOptionElements.length) % enabledOptionElements.length;
182+
const newOption = enabledOptionElements[newIndex];
183+
newOption.click();
184+
newOption.focus();
185+
}
186+
},
187+
[outerRef, role],
188+
);
189+
190+
const classes = classNames(Classes.SEGMENTED_CONTROL, className, {
191+
[Classes.FILL]: fill,
192+
[Classes.INLINE]: inline,
193+
});
194+
195+
const isAnySelected = options.some(option => selectedValue === option.value);
196+
197+
return (
198+
<div
199+
{...removeNonHTMLProps(htmlProps)}
200+
role={role}
201+
onKeyDown={handleKeyDown}
202+
className={classes}
203+
ref={mergeRefs(ref, outerRef)}
204+
>
205+
{options.map((option, index) => {
206+
const isSelected = selectedValue === option.value;
207+
return (
208+
<SegmentedControlOption<T>
209+
{...option}
210+
intent={intent}
211+
isSelected={isSelected}
212+
key={option.value}
213+
// eslint-disable-next-line @typescript-eslint/no-deprecated
214+
large={large}
215+
onClick={handleOptionClick}
216+
size={size}
217+
// eslint-disable-next-line @typescript-eslint/no-deprecated
218+
small={small}
219+
{...(role === "radiogroup"
220+
? {
221+
"aria-checked": isSelected,
222+
role: "radio",
223+
// "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
224+
// `!isAnySelected` accounts for case where no value is currently selected
225+
// (passed value/defaultValue is not one of the values of the passed options.)
226+
// In this case, set first item to be tabbable even though it's unselected.
227+
tabIndex: isSelected || (index === 0 && !isAnySelected) ? 0 : -1,
228+
}
229+
: {
230+
"aria-pressed": isSelected,
231+
})}
232+
/>
233+
);
234+
})}
235+
</div>
236+
);
237+
},
238+
);
217239
SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`;
218240

219-
interface SegmentedControlOptionProps
220-
extends OptionProps<string>,
241+
interface SegmentedControlOptionProps<T extends string = string>
242+
extends OptionProps<T>,
221243
Pick<SegmentedControlProps, "intent" | "small" | "large" | "size">,
222244
Pick<ButtonProps, "role" | "tabIndex">,
223245
React.AriaAttributes {
224246
isSelected: boolean;
225-
onClick: (value: string, targetElement: HTMLElement) => void;
247+
onClick: (value: T, targetElement: HTMLElement) => void;
226248
}
227249

228-
function SegmentedControlOption({ isSelected, label, onClick, value, ...buttonProps }: SegmentedControlOptionProps) {
250+
function SegmentedControlOption<T extends string = string>({
251+
isSelected,
252+
label,
253+
onClick,
254+
value,
255+
...buttonProps
256+
}: SegmentedControlOptionProps<T>) {
229257
const handleClick = React.useCallback(
230258
(event: React.MouseEvent<HTMLElement>) => onClick?.(value, event.currentTarget),
231259
[onClick, value],

packages/docs-app/src/examples/core-examples/common/alignmentSelect.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@ interface AlignmentSelectProps {
2929
onChange: (align: Alignment) => void;
3030
}
3131

32-
export const AlignmentSelect: React.FC<AlignmentSelectProps> = ({ align, label = "Align text", onChange }) => {
33-
const handleChange = React.useCallback((value: string) => onChange(value as Alignment), [onChange]);
34-
return (
35-
<FormGroup label={label}>
36-
<SegmentedControl fill={true} options={options} onValueChange={handleChange} size="small" value={align} />
37-
</FormGroup>
38-
);
39-
};
32+
export const AlignmentSelect: React.FC<AlignmentSelectProps> = ({ align, label = "Align text", onChange }) => (
33+
<FormGroup label={label}>
34+
<SegmentedControl<Alignment>
35+
fill={true}
36+
options={options}
37+
onValueChange={onChange}
38+
size="small"
39+
value={align}
40+
/>
41+
</FormGroup>
42+
);

packages/docs-app/src/examples/core-examples/common/booleanOrUndefinedSelect.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const BooleanOrUndefinedSelect: React.FC<BooleanOrUndefinedSelectProps> =
4040

4141
return (
4242
<FormGroup label={label}>
43-
<SegmentedControl
43+
<SegmentedControl<"undefined" | "false" | "true">
4444
fill={true}
4545
options={[
4646
{ disabled, value: "undefined" },

packages/docs-app/src/examples/core-examples/common/layoutSelect.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,17 @@ export interface LayoutSelectProps {
2626
}
2727

2828
/** Button radio group to switch between horizontal and vertical layouts. */
29-
export const LayoutSelect: React.FC<LayoutSelectProps> = ({ layout, onChange }) => {
30-
const handleChange = React.useCallback((value: string) => onChange(value as Layout), [onChange]);
31-
32-
return (
33-
<FormGroup label="Layout">
34-
<SegmentedControl
35-
fill={true}
36-
onValueChange={handleChange}
37-
options={[
38-
{ label: "Horizontal", value: "horizontal" },
39-
{ label: "Vertical", value: "vertical" },
40-
]}
41-
size="small"
42-
value={layout}
43-
/>
44-
</FormGroup>
45-
);
46-
};
29+
export const LayoutSelect: React.FC<LayoutSelectProps> = ({ layout, onChange }) => (
30+
<FormGroup label="Layout">
31+
<SegmentedControl<Layout>
32+
fill={true}
33+
onValueChange={onChange}
34+
options={[
35+
{ label: "Horizontal", value: "horizontal" },
36+
{ label: "Vertical", value: "vertical" },
37+
]}
38+
size="small"
39+
value={layout}
40+
/>
41+
</FormGroup>
42+
);

0 commit comments

Comments
 (0)