Skip to content

Commit

Permalink
[core] feat: new SegmentedControl component (#6454)
Browse files Browse the repository at this point in the history
Co-authored-by: Charles <cperinet@palantir.com>
Co-authored-by: Adi Dahiya <adahiya@palantir.com>
  • Loading branch information
3 people authored Nov 10, 2023
1 parent b023644 commit 507f7c5
Show file tree
Hide file tree
Showing 14 changed files with 444 additions and 22 deletions.
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

1 comment on commit 507f7c5

@adidahiya
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[core] feat: new SegmentedControl component (#6454)

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Please sign in to comment.