Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -86,8 +86,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's 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 @@ -36,6 +36,19 @@ 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={[
{ value: OptionType.VALUE_1 },
{ value: OptionType.VALUE_2 },
]}
/>
```

@## Props interface

@interface SegmentedControlProps
263 changes: 139 additions & 124 deletions packages/core/src/components/segmented-control/segmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,14 @@ import { Button } from "../button/buttons";

export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;

interface SegmentedControlOptionProps extends OptionProps<string> {
icon?: ButtonProps["icon"];
}
interface SegmentedControlOptionProps<T extends string = string> extends OptionProps<T>, Pick<ButtonProps, "icon"> {}

/**
* SegmentedControl component props.
*/
export interface SegmentedControlProps
export interface SegmentedControlProps<T extends string = string>
extends Props,
ControlledValueProps<string>,
ControlledValueProps<T>,
React.RefAttributes<HTMLDivElement> {
/**
* Whether this control should be disabled.
Expand Down Expand Up @@ -75,7 +73,7 @@ export interface SegmentedControlProps
/**
* List of available options.
*/
options: SegmentedControlOptionProps[];
options: Array<SegmentedControlOptionProps<T>>;

/**
* Aria role for the overall component (container).
Expand Down Expand Up @@ -107,151 +105,168 @@ export interface SegmentedControlProps
small?: boolean;
}

/**
* Generic 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.ReactNode;
}

/**
* 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,
disabled,
fill,
inline,
intent = Intent.NONE,
// eslint-disable-next-line @typescript-eslint/no-deprecated
large,
onValueChange,
options,
role = "radiogroup",
size = "medium",
// eslint-disable-next-line @typescript-eslint/no-deprecated
small,
value: controlledValue,
...htmlProps
} = props;
export const SegmentedControl: SegmentedControlComponent = React.forwardRef(
<T extends string>(props: SegmentedControlProps<T>, ref: React.ForwardedRef<HTMLDivElement>) => {
const {
className,
defaultValue,
disabled,
fill,
inline,
intent = Intent.NONE,
// eslint-disable-next-line @typescript-eslint/no-deprecated
large,
onValueChange,
options,
role = "radiogroup",
size = "medium",
// eslint-disable-next-line @typescript-eslint/no-deprecated
small,
value: controlledValue,
...htmlProps
} = props;

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

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

const handleOptionClick = React.useCallback(
(newSelectedValue: string, targetElement: HTMLElement) => {
setLocalValue(newSelectedValue);
onValueChange?.(newSelectedValue, targetElement);
},
[onValueChange],
);
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" || role === "menu") {
// 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 handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (role === "radiogroup" || role === "menu") {
// 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;
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;
// 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],
);
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 classes = classNames(Classes.SEGMENTED_CONTROL, className, {
[Classes.FILL]: fill,
[Classes.INLINE]: inline,
});

const isAnySelected = options.some(option => selectedValue === option.value);
const buttonRole = (
{
/* eslint-disable sort-keys */
radiogroup: "radio",
menu: "menuitemradio",
group: undefined,
toolbar: undefined,
/* eslint-enable sort-keys */
} satisfies Record<typeof role, React.AriaRole | undefined>
)[role];
const isAnySelected = options.some(option => selectedValue === option.value);
const buttonRole = (
{
/* eslint-disable sort-keys */
radiogroup: "radio",
menu: "menuitemradio",
group: undefined,
toolbar: undefined,
/* eslint-enable sort-keys */
} satisfies Record<typeof role, React.AriaRole | undefined>
)[role];

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}
disabled={option.disabled || disabled}
intent={intent}
isSelected={isSelected}
key={option.value}
// eslint-disable-next-line @typescript-eslint/no-deprecated
large={large}
onClick={handleOptionClick}
size={size}
// eslint-disable-next-line @typescript-eslint/no-deprecated
small={small}
role={buttonRole}
{...(role === "radiogroup" || role === "menu"
? {
"aria-checked": isSelected,
// "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>
);
});
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}
disabled={option.disabled || disabled}
intent={intent}
isSelected={isSelected}
key={option.value}
// eslint-disable-next-line @typescript-eslint/no-deprecated
large={large}
onClick={handleOptionClick}
size={size}
// eslint-disable-next-line @typescript-eslint/no-deprecated
small={small}
role={buttonRole}
{...(role === "radiogroup" || role === "menu"
? {
"aria-checked": isSelected,
// "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.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`;

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

function SegmentedControlOption({
function SegmentedControlOption<T extends string = string>({
isSelected,
label,
onClick,
value,
...buttonProps
}: SegmentedControlOptionComponentProps) {
}: SegmentedControlOptionComponentProps<T>) {
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => onClick?.(value, event.currentTarget),
[onClick, value],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ interface AlignmentSelectProps {
onChange: (align: Alignment) => void;
}

export const AlignmentSelect: React.FC<AlignmentSelectProps> = ({ align, label = "Align text", onChange }) => {
const handleChange = React.useCallback((value: string) => onChange(value as Alignment), [onChange]);
return (
<FormGroup label={label}>
<SegmentedControl fill={true} options={options} onValueChange={handleChange} size="small" value={align} />
</FormGroup>
);
};
export const AlignmentSelect: React.FC<AlignmentSelectProps> = ({ align, label = "Align text", onChange }) => (
<FormGroup label={label}>
<SegmentedControl<Alignment>
fill={true}
options={options}
onValueChange={onChange}
size="small"
value={align}
/>
</FormGroup>
);
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const BooleanOrUndefinedSelect: React.FC<BooleanOrUndefinedSelectProps> =

return (
<FormGroup label={label}>
<SegmentedControl
<SegmentedControl<"undefined" | "false" | "true">
fill={true}
options={[
{ disabled, value: "undefined" },
Expand Down
Loading