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 extends ItemProps { 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 . */ tooltipOptions?: Omit; } type Priority = 'default' | 'primary'; export interface SegmentedControlProps extends Omit, CollectionBase { defaultValue?: Value; disabled?: AriaRadioGroupProps['isDisabled']; onChange?: (value: Value) => void; priority?: Priority; size?: FormSize; value?: Value; } const collectionFactory = (nodes: Iterable>) => new ListCollection(nodes); export function SegmentedControl({ value, defaultValue, onChange, size = 'md', priority = 'default', disabled, ...props }: SegmentedControlProps) { const ref = useRef(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 ( {[...collectionList].map(option => ( {option.rendered} ))} ); } SegmentedControl.Item = Item as ( props: SegmentedControlItemProps ) => JSX.Element; interface SegmentProps extends Omit, keyof ItemProps>, AriaRadioProps { lastKey: string; layoutGroupId: string; priority: Priority; size: FormSize; state: RadioGroupState; nextKey?: string; prevKey?: string; } function Segment({ state, nextKey, prevKey, size, priority, layoutGroupId, tooltip, tooltipOptions = {}, ...props }: SegmentProps) { const ref = useRef(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 = ( {!isDisabled && ( )} {isSelected && ( )} {/* 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. */} {props.children} {props.children} ); if (tooltip) { return ( {content} ); } 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; min-width: 0; `; 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;`} `;