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

[core] feat: new SegmentedControl component #6454

Merged
merged 11 commits into from
Nov 10, 2023
2 changes: 2 additions & 0 deletions packages/core/src/common/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 27 additions & 9 deletions packages/core/src/common/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,25 +87,43 @@ 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<T, E extends HTMLElement = HTMLElement> {
/**
* 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<ControlledValueProps<string, HTMLInputElement>, "onChange">;

export interface OptionProps<T extends string | number = string | number> extends Props {
/** Whether this option is non-interactive. */
disabled?: boolean;

/** Label text for this option. If omitted, `value` is used as the label. */
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. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
@page checkbox
@page radio
@page html-select
@page segmented-control
@page sliders
@page switch

Expand Down
23 changes: 10 additions & 13 deletions packages/core/src/components/forms/inputGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HTMLInputElement>;

export interface InputGroupProps
extends Omit<HTMLInputProps, keyof ControlledProps>,
ControlledProps,
extends Omit<HTMLInputProps, keyof ControlledInputValueProps>,
ControlledInputValueProps,
InputSharedProps {
/**
* Set this to `true` if you will be controlling the `value` of this input with asynchronous updates.
Expand All @@ -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;

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
<SegmentedControl
options={[
{
label: "List",
value: "list",
},
{
label: "Grid",
value: "grid",
},
{
label: "Gallery",
value: "gallery",
},
]}
defaultValue="list"
/>
```

@## Props interface

@interface SegmentedControlProps
149 changes: 149 additions & 0 deletions packages/core/src/components/segmented-control/segmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -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<string>,
React.RefAttributes<HTMLDivElement> {
/**
* 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<OptionProps<string>>;

/**
* 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<SegmentedControlProps> = React.forwardRef((props, ref) => {
const {
className,
defaultValue,
fill,
inline,
intent,
large,
onValueChange,
options,
small,
value: controlledValue,
...htmlProps
} = props;

const [localValue, setLocalValue] = React.useState<string | undefined>(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 (
<div className={classes} ref={ref} {...removeNonHTMLProps(htmlProps)}>
{options.map(option => (
<SegmentedControlOption
{...option}
intent={intent}
isSelected={selectedValue === option.value}
key={option.value}
large={large}
onClick={handleOptionClick}
small={small}
/>
))}
</div>
);
});
SegmentedControl.defaultProps = {
defaultValue: undefined,
intent: Intent.NONE,
};
SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`;

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

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

return <Button onClick={handleClick} minimal={!isSelected} text={label} {...buttonProps} />;
}
SegmentedControlOption.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControlOption`;
Loading