Skip to content

Commit 0eefcda

Browse files
committed
feat(SegmentedControl): allow for passing stricter type for options
1 parent 04acffb commit 0eefcda

File tree

15 files changed

+238
-221
lines changed

15 files changed

+238
-221
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: 141 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;
3434
/**
3535
* SegmentedControl component props.
3636
*/
37-
export interface SegmentedControlProps
37+
export interface SegmentedControlProps<T extends string = string>
3838
extends Props,
39-
ControlledValueProps<string>,
39+
ControlledValueProps<T>,
4040
React.RefAttributes<HTMLDivElement> {
4141
/**
4242
* Whether the control should take up the full width of its container.
@@ -66,7 +66,7 @@ export interface SegmentedControlProps
6666
/**
6767
* List of available options.
6868
*/
69-
options: Array<OptionProps<string>>;
69+
options: Array<OptionProps<T>>;
7070

7171
/**
7272
* Aria role for the overall component. Child buttons get appropriate roles.
@@ -93,133 +93,161 @@ export interface SegmentedControlProps
9393
small?: boolean;
9494
}
9595

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

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

163-
const classes = classNames(Classes.SEGMENTED_CONTROL, className, {
164-
[Classes.FILL]: fill,
165-
[Classes.INLINE]: inline,
166-
});
140+
const outerRef = React.useRef<HTMLDivElement>(null);
167141

168-
const isAnySelected = options.some(option => selectedValue === option.value);
142+
const handleOptionClick = React.useCallback(
143+
(newSelectedValue: T, targetElement: HTMLElement) => {
144+
setLocalValue(newSelectedValue);
145+
onValueChange?.(newSelectedValue, targetElement);
146+
},
147+
[onValueChange],
148+
);
169149

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

213-
interface SegmentedControlOptionProps
214-
extends OptionProps<string>,
235+
interface SegmentedControlOptionProps<T extends string = string>
236+
extends OptionProps<T>,
215237
Pick<SegmentedControlProps, "intent" | "small" | "large" | "size">,
216238
Pick<ButtonProps, "role" | "tabIndex">,
217239
React.AriaAttributes {
218240
isSelected: boolean;
219-
onClick: (value: string, targetElement: HTMLElement) => void;
241+
onClick: (value: T, targetElement: HTMLElement) => void;
220242
}
221243

222-
function SegmentedControlOption({ isSelected, label, onClick, value, ...buttonProps }: SegmentedControlOptionProps) {
244+
function SegmentedControlOption<T extends string = string>({
245+
isSelected,
246+
label,
247+
onClick,
248+
value,
249+
...buttonProps
250+
}: SegmentedControlOptionProps<T>) {
223251
const handleClick = React.useCallback(
224252
(event: React.MouseEvent<HTMLElement>) => onClick?.(value, event.currentTarget),
225253
[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)