diff --git a/packages/core/src/components/icon/icon.tsx b/packages/core/src/components/icon/icon.tsx index 8e187635da..075a685c24 100644 --- a/packages/core/src/components/icon/icon.tsx +++ b/packages/core/src/components/icon/icon.tsx @@ -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 diff --git a/packages/core/src/components/segmented-control/segmented-control.md b/packages/core/src/components/segmented-control/segmented-control.md index 9faac93e80..b7c44314f2 100644 --- a/packages/core/src/components/segmented-control/segmented-control.md +++ b/packages/core/src/components/segmented-control/segmented-control.md @@ -40,6 +40,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 + + + options={[ + { value: OptionType.VALUE_1 }, + { value: OptionType.VALUE_2 }, + ]} +/> +``` + @## Props interface @interface SegmentedControlProps diff --git a/packages/core/src/components/segmented-control/segmentedControl.tsx b/packages/core/src/components/segmented-control/segmentedControl.tsx index ff638ae56c..60a88840f1 100644 --- a/packages/core/src/components/segmented-control/segmentedControl.tsx +++ b/packages/core/src/components/segmented-control/segmentedControl.tsx @@ -33,9 +33,9 @@ export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY; /** * SegmentedControl component props. */ -export interface SegmentedControlProps +export interface SegmentedControlProps extends Props, - ControlledValueProps, + ControlledValueProps, React.RefAttributes { /** * Whether the control should take up the full width of its container. @@ -64,7 +64,7 @@ export interface SegmentedControlProps /** * List of available options. */ - options: Array>; + options: Array>; /** * Aria role for the overall component. Child buttons get appropriate roles. @@ -83,127 +83,155 @@ 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 { + /** + * ReturnType here preserves type compatability with React 16 while we migrate to React 18. + * see: https://github.com/palantir/blueprint/pull/7142/files#r1915691062 + */ + // TODO(React 18): Replace return type with `React.ReactNode` once we drop support for React 16. + (props: SegmentedControlProps): ReturnType>> | null; +} + /** * Segmented control component. * * @see https://blueprintjs.com/docs/#core/components/segmented-control */ -export const SegmentedControl: React.FC = React.forwardRef((props, ref) => { - const { - className, - defaultValue, - fill, - inline, - intent = Intent.NONE, - large, - onValueChange, - options, - role = "radiogroup", - small, - value: controlledValue, - ...htmlProps - } = props; - - const [localValue, setLocalValue] = React.useState(defaultValue); - const selectedValue = controlledValue ?? localValue; - - const outerRef = React.useRef(null); - - const handleOptionClick = React.useCallback( - (newSelectedValue: string, targetElement: HTMLElement) => { - setLocalValue(newSelectedValue); - onValueChange?.(newSelectedValue, targetElement); - }, - [onValueChange], - ); - - const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - 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("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("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 ( -
- {options.map((option, index) => { - const isSelected = selectedValue === option.value; - return ( - - ); - })} -
- ); -}); +export const SegmentedControl: SegmentedControlComponent = React.forwardRef( + (props: SegmentedControlProps, ref: React.ForwardedRef) => { + const { + className, + defaultValue, + fill, + inline, + intent = Intent.NONE, + large, + onValueChange, + options, + role = "radiogroup", + small, + value: controlledValue, + ...htmlProps + } = props; + + const [localValue, setLocalValue] = React.useState(defaultValue); + const selectedValue = controlledValue ?? localValue; + + const outerRef = React.useRef(null); + + const handleOptionClick = React.useCallback( + (newSelectedValue: T, targetElement: HTMLElement) => { + setLocalValue(newSelectedValue); + onValueChange?.(newSelectedValue, targetElement); + }, + [onValueChange], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + 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("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("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 ( +
+ {options.map((option, index) => { + const isSelected = selectedValue === option.value; + return ( + + {...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, + })} + /> + ); + })} +
+ ); + }, +); SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`; -interface SegmentedControlOptionProps - extends OptionProps, +interface SegmentedControlOptionProps + extends OptionProps, Pick, Pick, 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({ + isSelected, + label, + onClick, + value, + ...buttonProps +}: SegmentedControlOptionProps) { const handleClick = React.useCallback( (event: React.MouseEvent) => onClick?.(value, event.currentTarget), [onClick, value], diff --git a/packages/docs-app/src/examples/core-examples/common/alignmentSelect.tsx b/packages/docs-app/src/examples/core-examples/common/alignmentSelect.tsx index 05270ed152..43f0ec6d3c 100644 --- a/packages/docs-app/src/examples/core-examples/common/alignmentSelect.tsx +++ b/packages/docs-app/src/examples/core-examples/common/alignmentSelect.tsx @@ -37,11 +37,15 @@ export const AlignmentSelect: React.FC = ({ { label: "Right", value: Alignment.RIGHT }, ].filter(Boolean); - const handleChange = React.useCallback((value: string) => onChange(value as Alignment), [onChange]); - return ( - + + small={true} + fill={true} + options={options} + onValueChange={onChange} + value={align} + /> ); }; diff --git a/packages/docs-app/src/examples/core-examples/common/layoutSelect.tsx b/packages/docs-app/src/examples/core-examples/common/layoutSelect.tsx index e391681367..aa6712183a 100644 --- a/packages/docs-app/src/examples/core-examples/common/layoutSelect.tsx +++ b/packages/docs-app/src/examples/core-examples/common/layoutSelect.tsx @@ -26,21 +26,17 @@ export interface LayoutSelectProps { } /** Button radio group to switch between horizontal and vertical layouts. */ -export const LayoutSelect: React.FC = ({ layout, onChange }) => { - const handleChange = React.useCallback((value: string) => onChange(value as Layout), [onChange]); - - return ( - - - - ); -}; +export const LayoutSelect: React.FC = ({ layout, onChange }) => ( + + + fill={true} + onValueChange={onChange} + options={[ + { label: "Horizontal", value: "horizontal" }, + { label: "Vertical", value: "vertical" }, + ]} + small={true} + value={layout} + /> + +); diff --git a/packages/docs-app/src/examples/core-examples/common/sizeSelect.tsx b/packages/docs-app/src/examples/core-examples/common/sizeSelect.tsx index a2b732dc7d..e209ad2d0d 100644 --- a/packages/docs-app/src/examples/core-examples/common/sizeSelect.tsx +++ b/packages/docs-app/src/examples/core-examples/common/sizeSelect.tsx @@ -29,25 +29,21 @@ export interface SizeSelectProps { onChange: (size: Size) => void; } -export const SizeSelect: React.FC = ({ label = "Size", size, optionLabels = labels, onChange }) => { - const handleChange = React.useCallback((value: string) => onChange(value as Size), [onChange]); - - return ( - - - - ); -}; +export const SizeSelect: React.FC = ({ label = "Size", size, optionLabels = labels, onChange }) => ( + + + fill={true} + small={true} + options={[ + { label: optionLabels[0], value: "small" }, + { label: optionLabels[1], value: "regular" }, + { label: optionLabels[2], value: "large" }, + ]} + onValueChange={onChange} + value={size} + /> + +); export function getSizeProp(size: Size) { switch (size) { diff --git a/packages/docs-app/src/examples/core-examples/drawerExample.tsx b/packages/docs-app/src/examples/core-examples/drawerExample.tsx index a3b44c9205..3646fa19a5 100644 --- a/packages/docs-app/src/examples/core-examples/drawerExample.tsx +++ b/packages/docs-app/src/examples/core-examples/drawerExample.tsx @@ -72,7 +72,7 @@ export class DrawerExample extends React.PureComponent this.setState({ usePortal })); - private handlePositionChange = (position: string) => this.setState({ position: position as Position }); + private handlePositionChange = (position: Position) => this.setState({ position }); private handleOutsideClickChange = handleBooleanChange(val => this.setState({ canOutsideClickClose: val })); @@ -147,7 +147,7 @@ export class DrawerExample extends React.PureComponent
Props
- fill={true} options={[ { value: Position.TOP }, diff --git a/packages/docs-app/src/examples/core-examples/menuItemExample.tsx b/packages/docs-app/src/examples/core-examples/menuItemExample.tsx index 4b2447eef3..5d211767db 100644 --- a/packages/docs-app/src/examples/core-examples/menuItemExample.tsx +++ b/packages/docs-app/src/examples/core-examples/menuItemExample.tsx @@ -44,11 +44,6 @@ export function MenuItemExample(props: ExampleProps) { const [submenuEnabled, setSubmenuEnabled] = React.useState(true); const [roleStructure, setRoleStructure] = React.useState("menuitem"); - const handleRoleStructureChange = React.useCallback( - (newValue: string) => setRoleStructure(newValue as MenuItemProps["roleStructure"]), - [], - ); - const isSelectable = roleStructure === "listoption"; const options = ( @@ -78,9 +73,9 @@ export function MenuItemExample(props: ExampleProps) { - options={[{ value: "menuitem" }, { value: "listoption" }]} - onValueChange={handleRoleStructureChange} + onValueChange={setRoleStructure} small={true} value={roleStructure} /> diff --git a/packages/docs-app/src/examples/core-examples/multistepDialogExample.tsx b/packages/docs-app/src/examples/core-examples/multistepDialogExample.tsx index 4350b9119f..70e0b389a4 100644 --- a/packages/docs-app/src/examples/core-examples/multistepDialogExample.tsx +++ b/packages/docs-app/src/examples/core-examples/multistepDialogExample.tsx @@ -52,7 +52,7 @@ export interface MultistepDialogExampleState { initialStepIndex: number; } -const NAV_POSITIONS = ["left", "top", "right"]; +const NAV_POSITIONS = ["left", "top", "right"] as const; export class MultistepDialogExample extends React.PureComponent< ExampleProps, @@ -90,8 +90,7 @@ export class MultistepDialogExample extends React.PureComponent< private handleHasTitleChange = handleBooleanChange(hasTitle => this.setState({ hasTitle })); - private handleNavPositionChange = (newValue: string) => - this.setState({ navPosition: newValue as MultistepDialogNavPosition }); + private handleNavPositionChange = (navPosition: MultistepDialogNavPosition) => this.setState({ navPosition }); public render() { const finalButtonProps: Partial = { @@ -171,7 +170,7 @@ export class MultistepDialogExample extends React.PureComponent< - fill={true} onValueChange={this.handleNavPositionChange} options={NAV_POSITIONS.map(p => ({ value: p }))} diff --git a/packages/docs-app/src/examples/core-examples/nonIdealStateExample.tsx b/packages/docs-app/src/examples/core-examples/nonIdealStateExample.tsx index 64a592366e..48f0b9450a 100644 --- a/packages/docs-app/src/examples/core-examples/nonIdealStateExample.tsx +++ b/packages/docs-app/src/examples/core-examples/nonIdealStateExample.tsx @@ -138,21 +138,17 @@ type NonIdealStateVisualKind = "icon" | "spinner"; const NonIdealStateVisualSelect: React.FC<{ visual: NonIdealStateVisualKind; onChange: (option: NonIdealStateVisualKind) => void; -}> = ({ visual, onChange }) => { - const handleChange = React.useCallback((value: string) => onChange(value as NonIdealStateVisualKind), [onChange]); - - return ( - - - - ); -}; +}> = ({ visual, onChange }) => ( + + + fill={true} + onValueChange={onChange} + options={[ + { label: "Icon", value: "icon" }, + { label: "Spinner", value: "spinner" }, + ]} + small={true} + value={visual} + /> + +); diff --git a/packages/docs-app/src/examples/core-examples/segmentedControlExample.tsx b/packages/docs-app/src/examples/core-examples/segmentedControlExample.tsx index e56045caf0..ba19aaf251 100644 --- a/packages/docs-app/src/examples/core-examples/segmentedControlExample.tsx +++ b/packages/docs-app/src/examples/core-examples/segmentedControlExample.tsx @@ -23,11 +23,6 @@ import { type Size, SizeSelect } from "./common/sizeSelect"; export const SegmentedControlExample: React.FC = props => { const [intent, setIntent] = React.useState("none"); - const handleIntentChange = React.useCallback( - (newIntent: string) => setIntent(newIntent as SegmentedControlIntent), - [], - ); - const [fill, setFill] = React.useState(false); const [inline, setInline] = React.useState(false); const [size, setSize] = React.useState("small"); @@ -39,14 +34,14 @@ export const SegmentedControlExample: React.FC = props => { - defaultValue="none" inline={true} options={[ { label: "None", value: "none" }, { label: "Primary", value: "primary" }, ]} - onValueChange={handleIntentChange} + onValueChange={setIntent} small={true} /> diff --git a/packages/icons/src/svgIconContainer.tsx b/packages/icons/src/svgIconContainer.tsx index e23cac5e9d..53118c669c 100644 --- a/packages/icons/src/svgIconContainer.tsx +++ b/packages/icons/src/svgIconContainer.tsx @@ -35,8 +35,8 @@ export type SVGIconContainerProps = Omit, "ch }; /** - * Generic icon container 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