123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- import {useMemo, useRef} from 'react';
- import {Theme} from '@emotion/react';
- import styled from '@emotion/styled';
- import {useRadio, useRadioGroup} from '@react-aria/radio';
- import {Item, useCollection} from '@react-stately/collections';
- import {ListCollection} from '@react-stately/list';
- import {RadioGroupState, useRadioGroupState} from '@react-stately/radio';
- import {AriaRadioGroupProps, AriaRadioProps} from '@react-types/radio';
- import {CollectionBase, ItemProps, Node} from '@react-types/shared';
- import {LayoutGroup, motion} from 'framer-motion';
- import InteractionStateLayer from 'sentry/components/interactionStateLayer';
- import {InternalTooltipProps, Tooltip} from 'sentry/components/tooltip';
- import {defined} from 'sentry/utils';
- import {FormSize} from 'sentry/utils/theme';
- export interface SegmentedControlItemProps<Value extends string> extends ItemProps<any> {
- key: Value;
- disabled?: boolean;
- /**
- * Optional tooltip that appears when the use hovers over the segment. Avoid using
- * tooltips if there are other, more visible ways to display the same information.
- */
- tooltip?: React.ReactNode;
- /**
- * Additional props to be passed into <Tooltip />.
- */
- tooltipOptions?: Omit<InternalTooltipProps, 'children' | 'title' | 'className'>;
- }
- type Priority = 'default' | 'primary';
- export interface SegmentedControlProps<Value extends string>
- extends Omit<AriaRadioGroupProps, 'value' | 'defaultValue' | 'onChange'>,
- CollectionBase<any> {
- defaultValue?: Value;
- disabled?: AriaRadioGroupProps['isDisabled'];
- onChange?: (value: Value) => void;
- priority?: Priority;
- size?: FormSize;
- value?: Value;
- }
- const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
- export function SegmentedControl<Value extends string>({
- value,
- defaultValue,
- onChange,
- size = 'md',
- priority = 'default',
- disabled,
- ...props
- }: SegmentedControlProps<Value>) {
- const ref = useRef<HTMLDivElement>(null);
- const collection = useCollection(props, collectionFactory);
- const ariaProps: AriaRadioGroupProps = {
- ...props,
- // Cast value/defaultValue as string to comply with AriaRadioGroupProps. This is safe
- // as value and defaultValue are already strings (their type, Value, extends string)
- value: value as string,
- defaultValue: defaultValue as string,
- onChange: onChange && (val => onChange(val as Value)),
- orientation: 'horizontal',
- isDisabled: disabled,
- };
- const state = useRadioGroupState(ariaProps);
- const {radioGroupProps} = useRadioGroup(ariaProps, state);
- const collectionList = useMemo(() => [...collection], [collection]);
- return (
- <GroupWrap {...radioGroupProps} size={size} priority={priority} ref={ref}>
- <LayoutGroup id={radioGroupProps.id}>
- {[...collectionList].map(option => (
- <Segment
- {...option.props}
- key={option.key}
- nextKey={option.nextKey}
- prevKey={option.prevKey}
- value={String(option.key)}
- isDisabled={option.props.disabled}
- state={state}
- size={size}
- priority={priority}
- layoutGroupId={radioGroupProps.id}
- >
- {option.rendered}
- </Segment>
- ))}
- </LayoutGroup>
- </GroupWrap>
- );
- }
- SegmentedControl.Item = Item as <Value extends string>(
- props: SegmentedControlItemProps<Value>
- ) => JSX.Element;
- interface SegmentProps<Value extends string>
- extends Omit<SegmentedControlItemProps<Value>, keyof ItemProps<any>>,
- AriaRadioProps {
- lastKey: string;
- layoutGroupId: string;
- priority: Priority;
- size: FormSize;
- state: RadioGroupState;
- nextKey?: string;
- prevKey?: string;
- }
- function Segment<Value extends string>({
- state,
- nextKey,
- prevKey,
- size,
- priority,
- layoutGroupId,
- tooltip,
- tooltipOptions = {},
- ...props
- }: SegmentProps<Value>) {
- const ref = useRef<HTMLInputElement>(null);
- const {inputProps} = useRadio({...props}, state, ref);
- const prevOptionIsSelected = defined(prevKey) && state.selectedValue === prevKey;
- const nextOptionIsSelected = defined(nextKey) && state.selectedValue === nextKey;
- const isSelected = state.selectedValue === props.value;
- const showDivider = !isSelected && !nextOptionIsSelected;
- const {isDisabled} = props;
- const content = (
- <SegmentWrap size={size} isSelected={isSelected} isDisabled={isDisabled}>
- <SegmentInput {...inputProps} ref={ref} />
- {!isDisabled && (
- <SegmentInteractionStateLayer
- nextOptionIsSelected={nextOptionIsSelected}
- prevOptionIsSelected={prevOptionIsSelected}
- />
- )}
- {isSelected && (
- <SegmentSelectionIndicator
- layoutId={layoutGroupId}
- transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
- priority={priority}
- aria-hidden
- />
- )}
- <Divider visible={showDivider} role="separator" aria-hidden />
- {/* Once an item is selected, it gets a heavier font weight and becomes slightly
- wider. To prevent layout shifts, we need a hidden container (HiddenLabel) that will
- always have normal weight to take up constant space; and a visible, absolutely
- positioned container (VisibleLabel) that doesn't affect the layout. */}
- <LabelWrap>
- <HiddenLabel aria-hidden>{props.children}</HiddenLabel>
- <VisibleLabel isSelected={isSelected} isDisabled={isDisabled} priority={priority}>
- {props.children}
- </VisibleLabel>
- </LabelWrap>
- </SegmentWrap>
- );
- if (tooltip) {
- return (
- <Tooltip
- skipWrapper
- title={tooltip}
- {...{delay: 500, position: 'bottom', ...tooltipOptions}}
- >
- {content}
- </Tooltip>
- );
- }
- return content;
- }
- const GroupWrap = styled('div')<{priority: Priority; size: FormSize}>`
- position: relative;
- display: inline-grid;
- grid-auto-flow: column;
- background: ${p =>
- p.priority === 'primary' ? p.theme.background : p.theme.backgroundTertiary};
- border: solid 1px ${p => p.theme.border};
- border-radius: ${p => p.theme.borderRadius};
- min-width: 0;
- ${p => p.theme.form[p.size]}
- `;
- const SegmentWrap = styled('label')<{
- isSelected: boolean;
- size: FormSize;
- isDisabled?: boolean;
- }>`
- position: relative;
- display: flex;
- margin: 0;
- border-radius: calc(${p => p.theme.borderRadius} - 1px);
- cursor: ${p => (p.isDisabled ? 'default' : 'pointer')};
- min-width: 0;
- ${p => p.theme.buttonPadding[p.size]}
- font-weight: 400;
- ${p =>
- !p.isDisabled &&
- `
- &:hover {
- background-color: inherit;
- [role='separator'] {
- opacity: 0;
- }
- }
- `}
- ${p => p.isSelected && `z-index: 1;`}
- `;
- const SegmentInput = styled('input')`
- appearance: none;
- position: absolute;
- top: 0;
- left: 0;
- bottom: 0;
- right: 0;
- border-radius: ${p => p.theme.borderRadius};
- transition: box-shadow 0.125s ease-out;
- z-index: -1;
- /* Reset global styles */
- && {
- padding: 0;
- margin: 0;
- }
- &:focus {
- outline: none;
- }
- `;
- const SegmentInteractionStateLayer = styled(InteractionStateLayer)<{
- nextOptionIsSelected: boolean;
- prevOptionIsSelected: boolean;
- }>`
- top: 0;
- left: 0;
- bottom: 0;
- right: 0;
- width: auto;
- height: auto;
- transform: none;
- /* Prevent small gaps between adjacent pairs of selected & hovered radios (due to their
- border radius) by extending the hovered radio's interaction state layer into and
- behind the selected radio. */
- transition: left 0.2s, right 0.2s;
- ${p => p.prevOptionIsSelected && `left: calc(-${p.theme.borderRadius} - 2px);`}
- ${p => p.nextOptionIsSelected && `right: calc(-${p.theme.borderRadius} - 2px);`}
- `;
- const SegmentSelectionIndicator = styled(motion.div)<{priority: Priority}>`
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- background: ${p =>
- p.priority === 'primary' ? p.theme.active : p.theme.backgroundElevated};
- border-radius: ${p =>
- p.priority === 'primary'
- ? p.theme.borderRadius
- : `calc(${p.theme.borderRadius} - 1px)`};
- box-shadow: 0 0 2px rgba(43, 34, 51, 0.32);
- input.focus-visible ~ & {
- box-shadow: ${p =>
- p.priority === 'primary'
- ? `0 0 0 3px ${p.theme.focus}`
- : `0 0 0 2px ${p.theme.focusBorder}`};
- }
- ${p =>
- p.priority === 'primary' &&
- `
- top: -1px;
- bottom: -1px;
- label:first-child > & {
- left: -1px;
- }
- label:last-child > & {
- right: -1px;
- }
- `}
- `;
- const LabelWrap = styled('span')`
- position: relative;
- display: flex;
- line-height: 1;
- `;
- const HiddenLabel = styled('span')`
- display: inline-block;
- margin: 0 2px;
- visibility: hidden;
- user-select: none;
- ${p => p.theme.overflowEllipsis}
- `;
- function getTextColor({
- isDisabled,
- isSelected,
- priority,
- theme,
- }: {
- isSelected: boolean;
- priority: Priority;
- theme: Theme;
- isDisabled?: boolean;
- }) {
- if (isDisabled) {
- return `color: ${theme.subText};`;
- }
- if (isSelected) {
- return priority === 'primary'
- ? `color: ${theme.white};`
- : `color: ${theme.headingColor};`;
- }
- return `color: ${theme.textColor};`;
- }
- const VisibleLabel = styled('span')<{
- isSelected: boolean;
- priority: Priority;
- isDisabled?: boolean;
- }>`
- position: absolute;
- top: 50%;
- left: 50%;
- width: max-content;
- transform: translate(-50%, -50%);
- transition: color 0.25s ease-out;
- user-select: none;
- font-weight: ${p => (p.isSelected ? 600 : 400)};
- letter-spacing: ${p => (p.isSelected ? '-0.015em' : 'inherit')};
- text-align: center;
- line-height: ${p => p.theme.text.lineHeightBody};
- ${getTextColor}
- ${p => p.theme.overflowEllipsis}
- `;
- const Divider = styled('div')<{visible: boolean}>`
- position: absolute;
- top: 50%;
- right: 0;
- width: 0;
- height: 50%;
- transform: translate(1px, -50%);
- border-right: solid 1px ${p => p.theme.innerBorder};
- label:last-child > & {
- display: none;
- }
- ${p => !p.visible && `opacity: 0;`}
- `;
|