Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SegmentedControl): allow for passing stricter type for options #7051

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ Options are specified as `OptionProps` objects, just like [RadioGroup](#core/com
/>
```

Options type is `string` by default, but can be made stricter, i.e.

```tsx
// enum OptionType

<SegmentedControl<OptionType>
options={[
{
label: OptionType.VALUE_1,
value: OptionType.VALUE_1,
},
{
label: OptionType.VALUE_2,
value: OptionType.VALUE_2,
},
]}
/>
```

@## Props interface

@interface SegmentedControlProps
242 changes: 130 additions & 112 deletions packages/core/src/components/segmented-control/segmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;
/**
* SegmentedControl component props.
*/
export interface SegmentedControlProps
export interface SegmentedControlProps<T extends string = string>
extends Props,
ControlledValueProps<string>,
ControlledValueProps<T>,
React.RefAttributes<HTMLDivElement> {
/**
* Whether the control should take up the full width of its container.
Expand Down Expand Up @@ -64,7 +64,7 @@ export interface SegmentedControlProps
/**
* List of available options.
*/
options: Array<OptionProps<string>>;
options: Array<OptionProps<T>>;

/**
* Aria role for the overall component. Child buttons get appropriate roles.
Expand All @@ -83,133 +83,151 @@ export interface SegmentedControlProps
small?: boolean;
}

// This allows the ability to pass a more strict type for `options`/`onValueChange`
// i.e. <SegmentedControl<Intent> />
interface ReactFCWithGeneric extends React.FC<SegmentedControlProps> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit this is a specific interface for SegmentedControl so lets name it something in accordance with that.

Suggested change
interface ReactFCWithGeneric extends React.FC<SegmentedControlProps> {
interface GenericSegmentedControl extends React.FC<SegmentedControlProps> {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✔️

Copy link
Contributor Author

@bvandercar-vt bvandercar-vt Nov 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also just as an FYI, I created this type to match the fact that all other components in this repo specify they are of type React.FC. But really, you don't need to manually add a type at all,-- you could just have

export const SegmentedControl = React.forwardRef(
    <T extends string>(props: SegmentedControlProps<T>, ref: React.ForwardedRef<HTMLDivElement>) => {

So let me know if you like that better?

<T extends string>(props: SegmentedControlProps<T>): ReturnType<React.FC<SegmentedControlProps<T>>>;
}

/**
* Segmented control component.
*
* @see https://blueprintjs.com/docs/#core/components/segmented-control
*/
export const SegmentedControl: React.FC<SegmentedControlProps> = React.forwardRef((props, ref) => {
const {
className,
defaultValue,
fill,
inline,
intent,
large,
onValueChange,
options,
role = "radiogroup",
small,
value: controlledValue,
...htmlProps
} = props;

const [localValue, setLocalValue] = React.useState<string | undefined>(defaultValue);
const selectedValue = controlledValue ?? localValue;

const outerRef = React.useRef<HTMLDivElement>(null);

const handleOptionClick = React.useCallback(
(newSelectedValue: string, targetElement: HTMLElement) => {
setLocalValue(newSelectedValue);
onValueChange?.(newSelectedValue, targetElement);
},
[onValueChange],
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (role === "radiogroup") {
// in a `radiogroup`, arrow keys select next item, not tab key.
const direction = Utils.getArrowKeyDirection(e, ["ArrowLeft", "ArrowUp"], ["ArrowRight", "ArrowDown"]);
const { current: outerElement } = outerRef;
if (direction === undefined || !outerElement) return;

const focusedElement = Utils.getActiveElement(outerElement)?.closest<HTMLButtonElement>("button");
if (!focusedElement) return;

// must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
const enabledOptionElements = Array.from(
outerElement.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"),
);
const focusedIndex = enabledOptionElements.indexOf(focusedElement);
if (focusedIndex < 0) return;

e.preventDefault();
// auto-wrapping at 0 and `length`
const newIndex =
(focusedIndex + direction + enabledOptionElements.length) % enabledOptionElements.length;
const newOption = enabledOptionElements[newIndex];
newOption.click();
newOption.focus();
}
},
[outerRef, role],
);

const classes = classNames(Classes.SEGMENTED_CONTROL, className, {
[Classes.FILL]: fill,
[Classes.INLINE]: inline,
});

const isAnySelected = options.some(option => selectedValue === option.value);

return (
<div
{...removeNonHTMLProps(htmlProps)}
role={role}
onKeyDown={handleKeyDown}
className={classes}
ref={mergeRefs(ref, outerRef)}
>
{options.map((option, index) => {
const isSelected = selectedValue === option.value;
return (
<SegmentedControlOption
{...option}
intent={intent}
isSelected={isSelected}
key={option.value}
large={large}
onClick={handleOptionClick}
small={small}
{...(role === "radiogroup"
? {
"aria-checked": isSelected,
role: "radio",
// "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
// `!isAnySelected` accounts for case where no value is currently selected
// (passed value/defaultValue is not one of the values of the passed options.)
// In this case, set first item to be tabbable even though it's unselected.
tabIndex: isSelected || (index === 0 && !isAnySelected) ? 0 : -1,
}
: {
"aria-pressed": isSelected,
})}
/>
);
})}
</div>
);
});
export const SegmentedControl: ReactFCWithGeneric = React.forwardRef(
<T extends string>(props: SegmentedControlProps<T>, ref: React.ForwardedRef<HTMLDivElement>) => {
const {
className,
defaultValue,
fill,
inline,
intent,
large,
onValueChange,
options,
role = "radiogroup",
small,
value: controlledValue,
...htmlProps
} = props;

const [localValue, setLocalValue] = React.useState<T | undefined>(defaultValue);
const selectedValue = controlledValue ?? localValue;

const outerRef = React.useRef<HTMLDivElement>(null);

const handleOptionClick = React.useCallback(
(newSelectedValue: T, targetElement: HTMLElement) => {
setLocalValue(newSelectedValue);
onValueChange?.(newSelectedValue, targetElement);
},
[onValueChange],
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (role === "radiogroup") {
// in a `radiogroup`, arrow keys select next item, not tab key.
const direction = Utils.getArrowKeyDirection(
e,
["ArrowLeft", "ArrowUp"],
["ArrowRight", "ArrowDown"],
);
const outerElement = outerRef.current;
if (direction === undefined || !outerElement) return;

const focusedElement = Utils.getActiveElement(outerElement)?.closest<HTMLButtonElement>("button");
if (!focusedElement) return;

// must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
const enabledOptionElements = Array.from(
outerElement.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"),
);
const focusedIndex = enabledOptionElements.indexOf(focusedElement);
if (focusedIndex < 0) return;

e.preventDefault();
// auto-wrapping at 0 and `length`
const newIndex =
(focusedIndex + direction + enabledOptionElements.length) % enabledOptionElements.length;
const newOption = enabledOptionElements[newIndex];
newOption.click();
newOption.focus();
}
},
[outerRef, role],
);

const classes = classNames(Classes.SEGMENTED_CONTROL, className, {
[Classes.FILL]: fill,
[Classes.INLINE]: inline,
});

const isAnySelected = options.some(option => selectedValue === option.value);

return (
<div
{...removeNonHTMLProps(htmlProps)}
role={role}
onKeyDown={handleKeyDown}
className={classes}
ref={mergeRefs(ref, outerRef)}
>
{options.map((option, index) => {
const isSelected = selectedValue === option.value;
return (
<SegmentedControlOption<T>
{...option}
intent={intent}
isSelected={isSelected}
key={option.value}
large={large}
onClick={handleOptionClick}
small={small}
{...(role === "radiogroup"
? {
"aria-checked": isSelected,
role: "radio",
// "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
// `!isAnySelected` accounts for case where no value is currently selected
// (passed value/defaultValue is not one of the values of the passed options.)
// In this case, set first item to be tabbable even though it's unselected.
tabIndex: isSelected || (index === 0 && !isAnySelected) ? 0 : -1,
}
: {
"aria-pressed": isSelected,
})}
/>
);
})}
</div>
);
},
);
SegmentedControl.defaultProps = {
defaultValue: undefined,
intent: Intent.NONE,
};
SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`;

interface SegmentedControlOptionProps
extends OptionProps<string>,
interface SegmentedControlOptionProps<T extends string = string>
extends OptionProps<T>,
Pick<SegmentedControlProps, "intent" | "small" | "large">,
Pick<ButtonProps, "role" | "tabIndex">,
React.AriaAttributes {
isSelected: boolean;
onClick: (value: string, targetElement: HTMLElement) => void;
onClick: (value: T, targetElement: HTMLElement) => void;
}

function SegmentedControlOption({ isSelected, label, onClick, value, ...buttonProps }: SegmentedControlOptionProps) {
function SegmentedControlOption<T extends string = string>({
isSelected,
label,
onClick,
value,
...buttonProps
}: SegmentedControlOptionProps<T>) {
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => onClick?.(value, event.currentTarget),
(event: React.MouseEvent<HTMLElement>) => onClick(value, event.currentTarget),
[onClick, value],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ export const AlignmentSelect: React.FC<AlignmentSelectProps> = ({
{ label: "Right", value: Alignment.RIGHT },
].filter(Boolean);

const handleChange = React.useCallback((value: string) => onChange(value as Alignment), [onChange]);

return (
<FormGroup label={label}>
<SegmentedControl small={true} fill={true} options={options} onValueChange={handleChange} value={align} />
<SegmentedControl<Alignment>
small={true}
fill={true}
options={options}
onValueChange={onChange}
value={align}
/>
</FormGroup>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,17 @@ export interface LayoutSelectProps {
}

/** Button radio group to switch between horizontal and vertical layouts. */
export const LayoutSelect: React.FC<LayoutSelectProps> = ({ layout, onChange }) => {
const handleChange = React.useCallback((value: string) => onChange(value as Layout), [onChange]);

return (
<FormGroup label="Layout">
<SegmentedControl
fill={true}
onValueChange={handleChange}
options={[
{ label: "Horizontal", value: "horizontal" },
{ label: "Vertical", value: "vertical" },
]}
small={true}
value={layout}
/>
</FormGroup>
);
};
export const LayoutSelect: React.FC<LayoutSelectProps> = ({ layout, onChange }) => (
<FormGroup label="Layout">
<SegmentedControl<Layout>
fill={true}
onValueChange={onChange}
options={[
{ label: "Horizontal", value: "horizontal" },
{ label: "Vertical", value: "vertical" },
]}
small={true}
value={layout}
/>
</FormGroup>
);
Loading