segmentedControl.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import {useMemo, useRef} from 'react';
  2. import {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {useRadio, useRadioGroup} from '@react-aria/radio';
  5. import {Item, useCollection} from '@react-stately/collections';
  6. import {ListCollection} from '@react-stately/list';
  7. import {RadioGroupState, useRadioGroupState} from '@react-stately/radio';
  8. import {AriaRadioGroupProps, AriaRadioProps} from '@react-types/radio';
  9. import {CollectionBase, ItemProps, Node} from '@react-types/shared';
  10. import {LayoutGroup, motion} from 'framer-motion';
  11. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  12. import {InternalTooltipProps, Tooltip} from 'sentry/components/tooltip';
  13. import {defined} from 'sentry/utils';
  14. import {FormSize} from 'sentry/utils/theme';
  15. export interface SegmentedControlItemProps<Value extends string> extends ItemProps<any> {
  16. key: Value;
  17. disabled?: boolean;
  18. /**
  19. * Optional tooltip that appears when the use hovers over the segment. Avoid using
  20. * tooltips if there are other, more visible ways to display the same information.
  21. */
  22. tooltip?: React.ReactNode;
  23. /**
  24. * Additional props to be passed into <Tooltip />.
  25. */
  26. tooltipOptions?: Omit<InternalTooltipProps, 'children' | 'title' | 'className'>;
  27. }
  28. type Priority = 'default' | 'primary';
  29. export interface SegmentedControlProps<Value extends string>
  30. extends Omit<AriaRadioGroupProps, 'value' | 'defaultValue' | 'onChange'>,
  31. CollectionBase<any> {
  32. defaultValue?: Value;
  33. disabled?: AriaRadioGroupProps['isDisabled'];
  34. onChange?: (value: Value) => void;
  35. priority?: Priority;
  36. size?: FormSize;
  37. value?: Value;
  38. }
  39. const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nodes);
  40. export function SegmentedControl<Value extends string>({
  41. value,
  42. defaultValue,
  43. onChange,
  44. size = 'md',
  45. priority = 'default',
  46. disabled,
  47. ...props
  48. }: SegmentedControlProps<Value>) {
  49. const ref = useRef<HTMLDivElement>(null);
  50. const collection = useCollection(props, collectionFactory);
  51. const ariaProps: AriaRadioGroupProps = {
  52. ...props,
  53. // Cast value/defaultValue as string to comply with AriaRadioGroupProps. This is safe
  54. // as value and defaultValue are already strings (their type, Value, extends string)
  55. value: value as string,
  56. defaultValue: defaultValue as string,
  57. onChange: onChange && (val => onChange(val as Value)),
  58. orientation: 'horizontal',
  59. isDisabled: disabled,
  60. };
  61. const state = useRadioGroupState(ariaProps);
  62. const {radioGroupProps} = useRadioGroup(ariaProps, state);
  63. const collectionList = useMemo(() => [...collection], [collection]);
  64. return (
  65. <GroupWrap {...radioGroupProps} size={size} priority={priority} ref={ref}>
  66. <LayoutGroup id={radioGroupProps.id}>
  67. {[...collectionList].map(option => (
  68. <Segment
  69. {...option.props}
  70. key={option.key}
  71. nextKey={option.nextKey}
  72. prevKey={option.prevKey}
  73. value={String(option.key)}
  74. isDisabled={option.props.disabled}
  75. state={state}
  76. size={size}
  77. priority={priority}
  78. layoutGroupId={radioGroupProps.id}
  79. >
  80. {option.rendered}
  81. </Segment>
  82. ))}
  83. </LayoutGroup>
  84. </GroupWrap>
  85. );
  86. }
  87. SegmentedControl.Item = Item as <Value extends string>(
  88. props: SegmentedControlItemProps<Value>
  89. ) => JSX.Element;
  90. interface SegmentProps<Value extends string>
  91. extends Omit<SegmentedControlItemProps<Value>, keyof ItemProps<any>>,
  92. AriaRadioProps {
  93. lastKey: string;
  94. layoutGroupId: string;
  95. priority: Priority;
  96. size: FormSize;
  97. state: RadioGroupState;
  98. nextKey?: string;
  99. prevKey?: string;
  100. }
  101. function Segment<Value extends string>({
  102. state,
  103. nextKey,
  104. prevKey,
  105. size,
  106. priority,
  107. layoutGroupId,
  108. tooltip,
  109. tooltipOptions = {},
  110. ...props
  111. }: SegmentProps<Value>) {
  112. const ref = useRef<HTMLInputElement>(null);
  113. const {inputProps} = useRadio({...props}, state, ref);
  114. const prevOptionIsSelected = defined(prevKey) && state.selectedValue === prevKey;
  115. const nextOptionIsSelected = defined(nextKey) && state.selectedValue === nextKey;
  116. const isSelected = state.selectedValue === props.value;
  117. const showDivider = !isSelected && !nextOptionIsSelected;
  118. const {isDisabled} = props;
  119. const content = (
  120. <SegmentWrap
  121. size={size}
  122. isSelected={isSelected}
  123. isDisabled={isDisabled}
  124. data-test-id={props.value}
  125. >
  126. <SegmentInput {...inputProps} ref={ref} />
  127. {!isDisabled && (
  128. <SegmentInteractionStateLayer
  129. nextOptionIsSelected={nextOptionIsSelected}
  130. prevOptionIsSelected={prevOptionIsSelected}
  131. />
  132. )}
  133. {isSelected && (
  134. <SegmentSelectionIndicator
  135. layoutId={layoutGroupId}
  136. transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
  137. priority={priority}
  138. aria-hidden
  139. />
  140. )}
  141. <Divider visible={showDivider} role="separator" aria-hidden />
  142. {/* Once an item is selected, it gets a heavier font weight and becomes slightly
  143. wider. To prevent layout shifts, we need a hidden container (HiddenLabel) that will
  144. always have normal weight to take up constant space; and a visible, absolutely
  145. positioned container (VisibleLabel) that doesn't affect the layout. */}
  146. <LabelWrap>
  147. <HiddenLabel aria-hidden>{props.children}</HiddenLabel>
  148. <VisibleLabel isSelected={isSelected} isDisabled={isDisabled} priority={priority}>
  149. {props.children}
  150. </VisibleLabel>
  151. </LabelWrap>
  152. </SegmentWrap>
  153. );
  154. if (tooltip) {
  155. return (
  156. <Tooltip
  157. skipWrapper
  158. title={tooltip}
  159. {...{delay: 500, position: 'bottom', ...tooltipOptions}}
  160. >
  161. {content}
  162. </Tooltip>
  163. );
  164. }
  165. return content;
  166. }
  167. const GroupWrap = styled('div')<{priority: Priority; size: FormSize}>`
  168. position: relative;
  169. display: inline-grid;
  170. grid-auto-flow: column;
  171. background: ${p =>
  172. p.priority === 'primary' ? p.theme.background : p.theme.backgroundTertiary};
  173. border: solid 1px ${p => p.theme.border};
  174. border-radius: ${p => p.theme.borderRadius};
  175. min-width: 0;
  176. ${p => p.theme.form[p.size]}
  177. `;
  178. const SegmentWrap = styled('label')<{
  179. isSelected: boolean;
  180. size: FormSize;
  181. isDisabled?: boolean;
  182. }>`
  183. position: relative;
  184. display: flex;
  185. margin: 0;
  186. border-radius: calc(${p => p.theme.borderRadius} - 1px);
  187. cursor: ${p => (p.isDisabled ? 'default' : 'pointer')};
  188. min-width: 0;
  189. ${p => p.theme.buttonPadding[p.size]}
  190. font-weight: 400;
  191. ${p =>
  192. !p.isDisabled &&
  193. `
  194. &:hover {
  195. background-color: inherit;
  196. [role='separator'] {
  197. opacity: 0;
  198. }
  199. }
  200. `}
  201. ${p => p.isSelected && `z-index: 1;`}
  202. `;
  203. const SegmentInput = styled('input')`
  204. appearance: none;
  205. position: absolute;
  206. top: 0;
  207. left: 0;
  208. bottom: 0;
  209. right: 0;
  210. border-radius: ${p => p.theme.borderRadius};
  211. transition: box-shadow 0.125s ease-out;
  212. z-index: -1;
  213. /* Reset global styles */
  214. && {
  215. padding: 0;
  216. margin: 0;
  217. }
  218. &:focus {
  219. outline: none;
  220. }
  221. `;
  222. const SegmentInteractionStateLayer = styled(InteractionStateLayer)<{
  223. nextOptionIsSelected: boolean;
  224. prevOptionIsSelected: boolean;
  225. }>`
  226. top: 0;
  227. left: 0;
  228. bottom: 0;
  229. right: 0;
  230. width: auto;
  231. height: auto;
  232. transform: none;
  233. /* Prevent small gaps between adjacent pairs of selected & hovered radios (due to their
  234. border radius) by extending the hovered radio's interaction state layer into and
  235. behind the selected radio. */
  236. transition: left 0.2s, right 0.2s;
  237. ${p => p.prevOptionIsSelected && `left: calc(-${p.theme.borderRadius} - 2px);`}
  238. ${p => p.nextOptionIsSelected && `right: calc(-${p.theme.borderRadius} - 2px);`}
  239. `;
  240. const SegmentSelectionIndicator = styled(motion.div)<{priority: Priority}>`
  241. position: absolute;
  242. top: 0;
  243. bottom: 0;
  244. left: 0;
  245. right: 0;
  246. background: ${p =>
  247. p.priority === 'primary' ? p.theme.active : p.theme.backgroundElevated};
  248. border-radius: ${p =>
  249. p.priority === 'primary'
  250. ? p.theme.borderRadius
  251. : `calc(${p.theme.borderRadius} - 1px)`};
  252. box-shadow: 0 0 2px rgba(43, 34, 51, 0.32);
  253. input.focus-visible ~ & {
  254. box-shadow: ${p =>
  255. p.priority === 'primary'
  256. ? `0 0 0 3px ${p.theme.focus}`
  257. : `0 0 0 2px ${p.theme.focusBorder}`};
  258. }
  259. ${p =>
  260. p.priority === 'primary' &&
  261. `
  262. top: -1px;
  263. bottom: -1px;
  264. label:first-child > & {
  265. left: -1px;
  266. }
  267. label:last-child > & {
  268. right: -1px;
  269. }
  270. `}
  271. `;
  272. const LabelWrap = styled('span')`
  273. position: relative;
  274. display: flex;
  275. line-height: 1;
  276. min-width: 0;
  277. `;
  278. const HiddenLabel = styled('span')`
  279. display: inline-block;
  280. margin: 0 2px;
  281. visibility: hidden;
  282. user-select: none;
  283. ${p => p.theme.overflowEllipsis}
  284. `;
  285. function getTextColor({
  286. isDisabled,
  287. isSelected,
  288. priority,
  289. theme,
  290. }: {
  291. isSelected: boolean;
  292. priority: Priority;
  293. theme: Theme;
  294. isDisabled?: boolean;
  295. }) {
  296. if (isDisabled) {
  297. return `color: ${theme.subText};`;
  298. }
  299. if (isSelected) {
  300. return priority === 'primary'
  301. ? `color: ${theme.white};`
  302. : `color: ${theme.headingColor};`;
  303. }
  304. return `color: ${theme.textColor};`;
  305. }
  306. const VisibleLabel = styled('span')<{
  307. isSelected: boolean;
  308. priority: Priority;
  309. isDisabled?: boolean;
  310. }>`
  311. position: absolute;
  312. top: 50%;
  313. left: 50%;
  314. width: max-content;
  315. transform: translate(-50%, -50%);
  316. transition: color 0.25s ease-out;
  317. user-select: none;
  318. font-weight: ${p => (p.isSelected ? 600 : 400)};
  319. letter-spacing: ${p => (p.isSelected ? '-0.015em' : 'inherit')};
  320. text-align: center;
  321. line-height: ${p => p.theme.text.lineHeightBody};
  322. ${getTextColor}
  323. ${p => p.theme.overflowEllipsis}
  324. `;
  325. const Divider = styled('div')<{visible: boolean}>`
  326. position: absolute;
  327. top: 50%;
  328. right: 0;
  329. width: 0;
  330. height: 50%;
  331. transform: translate(1px, -50%);
  332. border-right: solid 1px ${p => p.theme.innerBorder};
  333. label:last-child > & {
  334. display: none;
  335. }
  336. ${p => !p.visible && `opacity: 0;`}
  337. `;