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
4 changes: 2 additions & 2 deletions packages/core/src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export interface DefaultIconProps extends IntentProps, Props, DefaultSVGIconProp
}

/**
* Generic icon component type. This is essentially a type hack required to make forwardRef work with generic
* components. Note that this slows down TypeScript compilation, but it better than the alternative of globally
* Generic component type. This is essentially a type hack required to make forwardRef work with generic
* components. Note that this slows down TypeScript compilation, but is better than the alternative of globally
* augmenting "@types/react".
*
* @see https://stackoverflow.com/a/73795494/7406866
Expand Down
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
247 changes: 135 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,156 @@ export interface SegmentedControlProps
small?: boolean;
}

/**
* Generic component type. This is essentially a type hack required to make forwardRef work with generic
* components. Note that this slows down TypeScript compilation, but is better than the alternative of globally
* augmenting "@types/react".
*
* @see https://stackoverflow.com/a/73795494/7406866
*/
export interface SegmentedControlComponent extends React.FC<SegmentedControlProps> {
<T extends string>(props: SegmentedControlProps<T>): React.ReactElement | null;
}

/**
* 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 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
{...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: SegmentedControlComponent = 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