diff --git a/packages/core/src/common/classes.ts b/packages/core/src/common/classes.ts index 419565a3a9..7f19b32329 100644 --- a/packages/core/src/common/classes.ts +++ b/packages/core/src/common/classes.ts @@ -314,6 +314,8 @@ export const SPINNER_HEAD = `${SPINNER}-head`; export const SPINNER_NO_SPIN = `${NS}-no-spin`; export const SPINNER_TRACK = `${SPINNER}-track`; +export const SEGMENTED_CONTROL = `${NS}-segmented-control`; + export const TAB = `${NS}-tab`; export const TAB_ICON = `${TAB}-icon`; export const TAB_TAG = `${TAB}-tag`; diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 6be6243bca..8681801731 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -27,7 +27,9 @@ export { KeyCodes as Keys } from "./keyCodes"; export { Position } from "./position"; export { type ActionProps, + // eslint-disable-next-line deprecation/deprecation type ControlledProps, + type ControlledValueProps, type IntentProps, type LinkProps, type OptionProps, diff --git a/packages/core/src/common/props.ts b/packages/core/src/common/props.ts index 44a1eff8dc..0cc51a1959 100644 --- a/packages/core/src/common/props.ts +++ b/packages/core/src/common/props.ts @@ -87,17 +87,35 @@ export interface LinkProps { } /** - * Interface for a controlled input. + * Interface for a controlled or uncontrolled component, typically a form control. */ -export interface ControlledProps { - /** Initial value of the input, for uncontrolled usage. */ - defaultValue?: string; - - /** Form value of the input, for controlled usage. */ - value?: string; +export interface ControlledValueProps { + /** + * Initial value for uncontrolled usage. Mutually exclusive with `value` prop. + */ + defaultValue?: T; + + /** + * Controlled value. Mutually exclusive with `defaultValue` prop. + */ + value?: T; + + /** + * Callback invoked when the component value changes, typically via user interaction, in both controlled and + * uncontrolled mode. + * + * Using this prop instead of `onChange` can help avoid common bugs in React 16 related to Event Pooling + * where developers forget to save the text value from a change event or call `event.persist()`. + * + * @see https://legacy.reactjs.org/docs/legacy-event-pooling.html + */ + onValueChange?: (value: T, targetElement: E | null) => void; } -export interface OptionProps extends Props { +/** @deprecated use `ControlledValueProps` */ +export type ControlledProps = Omit, "onChange">; + +export interface OptionProps extends Props { /** Whether this option is non-interactive. */ disabled?: boolean; @@ -105,7 +123,7 @@ export interface OptionProps extends Props { label?: string; /** Value of this option. */ - value: string | number; + value: T; } /** A collection of curated prop keys used across our Components which are not valid HTMLElement props. */ diff --git a/packages/core/src/components/_index.scss b/packages/core/src/components/_index.scss index 24052e90a6..235fb1cb32 100644 --- a/packages/core/src/components/_index.scss +++ b/packages/core/src/components/_index.scss @@ -34,6 +34,7 @@ @import "portal/portal"; @import "progress-bar/progress-bar"; @import "section/section"; +@import "segmented-control/segmented-control"; @import "skeleton/skeleton"; @import "slider/slider"; @import "spinner/spinner"; diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index eb61d1d589..62559e08d9 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -40,6 +40,7 @@ @page checkbox @page radio @page html-select +@page segmented-control @page sliders @page switch diff --git a/packages/core/src/components/forms/inputGroup.tsx b/packages/core/src/components/forms/inputGroup.tsx index 327d67f780..db1b8b594f 100644 --- a/packages/core/src/components/forms/inputGroup.tsx +++ b/packages/core/src/components/forms/inputGroup.tsx @@ -19,14 +19,21 @@ import * as React from "react"; import { AbstractPureComponent, Classes } from "../../common"; import * as Errors from "../../common/errors"; -import { type ControlledProps, DISPLAYNAME_PREFIX, type HTMLInputProps, removeNonHTMLProps } from "../../common/props"; +import { + type ControlledValueProps, + DISPLAYNAME_PREFIX, + type HTMLInputProps, + removeNonHTMLProps, +} from "../../common/props"; import { Icon } from "../icon/icon"; import { AsyncControllableInput } from "./asyncControllableInput"; import type { InputSharedProps } from "./inputSharedProps"; +type ControlledInputValueProps = ControlledValueProps; + export interface InputGroupProps - extends Omit, - ControlledProps, + extends Omit, + ControlledInputValueProps, InputSharedProps { /** * Set this to `true` if you will be controlling the `value` of this input with asynchronous updates. @@ -40,16 +47,6 @@ export interface InputGroupProps /** Whether this input should use large styles. */ large?: boolean; - /** - * Callback invoked when the input value changes, typically via keyboard interactions. - * - * Using this prop instead of `onChange` can help avoid common bugs in React 16 related to Event Pooling - * where developers forget to save the text value from a change event or call `event.persist()`. - * - * @see https://legacy.reactjs.org/docs/legacy-event-pooling.html - */ - onValueChange?(value: string, targetElement: HTMLInputElement | null): void; - /** Whether this input should use small styles. */ small?: boolean; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 4cb2ecc518..491d583404 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -101,6 +101,11 @@ export { MultiSlider, type MultiSliderProps, type SliderBaseProps } from "./slid export { type NumberRange, RangeSlider, type RangeSliderProps } from "./slider/rangeSlider"; export { Section, type SectionElevation, type SectionProps } from "./section/section"; export { SectionCard, type SectionCardProps } from "./section/sectionCard"; +export { + SegmentedControl, + type SegmentedControlIntent, + type SegmentedControlProps, +} from "./segmented-control/segmentedControl"; export { Slider, type SliderProps } from "./slider/slider"; export { Spinner, type SpinnerProps, SpinnerSize } from "./spinner/spinner"; export { CheckboxCard, type CheckboxCardProps } from "./control-card/checkboxCard"; diff --git a/packages/core/src/components/segmented-control/_segmented-control.scss b/packages/core/src/components/segmented-control/_segmented-control.scss new file mode 100644 index 0000000000..cd5b80a1b7 --- /dev/null +++ b/packages/core/src/components/segmented-control/_segmented-control.scss @@ -0,0 +1,54 @@ +@use "sass:math"; +@import "../../common/variables"; + +.#{$ns}-segmented-control { + background-color: $light-gray5; + border-radius: $pt-border-radius; + display: flex; + gap: 3px; + padding: 3px; + + &.#{$ns}-inline { + display: inline-flex; + } + + &.#{$ns}-fill { + width: 100%; + + > .#{$ns}-button { + flex-grow: 1; + } + } + + > .#{$ns}-button:not(.#{$ns}-minimal) { + box-shadow: 0 0 0 1px $pt-divider-black-muted; + + &:not(.#{$ns}-intent-primary) { + background-color: $white; + + .#{$ns}-dark & { + background-color: $dark-gray5; + } + } + } + + > .#{$ns}-button.#{$ns}-minimal { + color: $pt-text-color-muted; + + .#{$ns}-dark & { + color: $pt-dark-text-color-muted; + } + } + + > .#{$ns}-button.#{$ns}-minimal:disabled { + color: $pt-text-color-disabled; + + .#{$ns}-dark & { + color: $pt-dark-text-color-disabled; + } + } + + .#{$ns}-dark & { + background-color: $dark-gray3; + } +} diff --git a/packages/core/src/components/segmented-control/segmented-control.md b/packages/core/src/components/segmented-control/segmented-control.md new file mode 100644 index 0000000000..9faac93e80 --- /dev/null +++ b/packages/core/src/components/segmented-control/segmented-control.md @@ -0,0 +1,45 @@ +--- +tag: new +--- + +@# Segmented control + +A **SegmentedControl** is a linear collection of buttons which allows a user to choose an option from multiple choices, +similar to a [**Radio**](#core/components/radio) group. + +Compared to the [**ButtonGroup**](#core/components/button-group) component, **SegmentedControl** has affordances +to signify a selection UI and a reduced visual weight which is appropriate for forms. + +@reactExample SegmentedControlExample + +@## Usage + +**SegmentedControl** can be used as either a controlled or uncontrolled component with the `value`, `defaultValue`, +and `onChange` props. + +Options are specified as `OptionProps` objects, just like [RadioGroup](#core/components/radio.radiogroup) and +[HTMLSelect](#core/components/html-select). + +```tsx + +``` + +@## Props interface + +@interface SegmentedControlProps diff --git a/packages/core/src/components/segmented-control/segmentedControl.tsx b/packages/core/src/components/segmented-control/segmentedControl.tsx new file mode 100644 index 0000000000..2f84c625a1 --- /dev/null +++ b/packages/core/src/components/segmented-control/segmentedControl.tsx @@ -0,0 +1,149 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import classNames from "classnames"; +import * as React from "react"; + +import { Classes, Intent } from "../../common"; +import { + type ControlledValueProps, + DISPLAYNAME_PREFIX, + type OptionProps, + type Props, + removeNonHTMLProps, +} from "../../common/props"; +import { Button } from "../button/buttons"; + +export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY; + +/** + * SegmentedControl component props. + */ +export interface SegmentedControlProps + extends Props, + ControlledValueProps, + React.RefAttributes { + /** + * Whether the control should take up the full width of its container. + * + * @default false + */ + fill?: boolean; + + /** + * Whether the control should appear as an inline element. + */ + inline?: boolean; + + /** + * Whether this control should use large buttons. + * + * @default false + */ + large?: boolean; + + /** + * Visual intent to apply to the selected value. + */ + intent?: SegmentedControlIntent; + + /** + * List of available options. + */ + options: Array>; + + /** + * Whether this control should use small buttons. + * + * @default false + */ + small?: boolean; +} + +/** + * 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, + large, + onValueChange, + options, + small, + value: controlledValue, + ...htmlProps + } = props; + + const [localValue, setLocalValue] = React.useState(defaultValue); + const selectedValue = controlledValue ?? localValue; + + const handleOptionClick = React.useCallback( + (newSelectedValue: string, targetElement: HTMLElement) => { + setLocalValue(newSelectedValue); + onValueChange?.(newSelectedValue, targetElement); + }, + [onValueChange], + ); + + const classes = classNames(Classes.SEGMENTED_CONTROL, className, { + [Classes.FILL]: fill, + [Classes.INLINE]: inline, + }); + + return ( +
+ {options.map(option => ( + + ))} +
+ ); +}); +SegmentedControl.defaultProps = { + defaultValue: undefined, + intent: Intent.NONE, +}; +SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`; + +interface SegmentedControlOptionProps + extends OptionProps, + Pick { + isSelected: boolean; + onClick: (value: string, targetElement: HTMLElement) => void; +} + +function SegmentedControlOption({ isSelected, label, onClick, value, ...buttonProps }: SegmentedControlOptionProps) { + const handleClick = React.useCallback( + (event: React.MouseEvent) => onClick?.(value, event.currentTarget), + [onClick, value], + ); + + return