segmentedControl.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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 size={size} isSelected={isSelected} isDisabled={isDisabled}>
  121. <SegmentInput {...inputProps} ref={ref} />
  122. {!isDisabled && (
  123. <SegmentInteractionStateLayer
  124. nextOptionIsSelected={nextOptionIsSelected}
  125. prevOptionIsSelected={prevOptionIsSelected}
  126. />
  127. )}
  128. {isSelected && (
  129. <SegmentSelectionIndicator
  130. layoutId={layoutGroupId}
  131. transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
  132. priority={priority}
  133. aria-hidden
  134. />
  135. )}
  136. <Divider visible={showDivider} role="separator" aria-hidden />
  137. {/* Once an item is selected, it gets a heavier font weight and becomes slightly
  138. wider. To prevent layout shifts, we need a hidden container (HiddenLabel) that will
  139. always have normal weight to take up constant space; and a visible, absolutely
  140. positioned container (VisibleLabel) that doesn't affect the layout. */}
  141. <LabelWrap>
  142. <HiddenLabel aria-hidden>{props.children}</HiddenLabel>
  143. <VisibleLabel isSelected={isSelected} isDisabled={isDisabled} priority={priority}>
  144. {props.children}
  145. </VisibleLabel>
  146. </LabelWrap>
  147. </SegmentWrap>
  148. );
  149. if (tooltip) {
  150. return (
  151. <Tooltip
  152. skipWrapper
  153. title={tooltip}
  154. {...{delay: 500, position: 'bottom', ...tooltipOptions}}
  155. >
  156. {content}
  157. </Tooltip>
  158. );
  159. }
  160. return content;
  161. }
  162. const GroupWrap = styled('div')<{priority: Priority; size: FormSize}>`
  163. position: relative;
  164. display: inline-grid;
  165. grid-auto-flow: column;
  166. background: ${p =>
  167. p.priority === 'primary' ? p.theme.background : p.theme.backgroundTertiary};
  168. border: solid 1px ${p => p.theme.border};
  169. border-radius: ${p => p.theme.borderRadius};
  170. min-width: 0;
  171. ${p => p.theme.form[p.size]}
  172. `;
  173. const SegmentWrap = styled('label')<{
  174. isSelected: boolean;
  175. size: FormSize;
  176. isDisabled?: boolean;
  177. }>`
  178. position: relative;
  179. display: flex;
  180. margin: 0;
  181. border-radius: calc(${p => p.theme.borderRadius} - 1px);
  182. cursor: ${p => (p.isDisabled ? 'default' : 'pointer')};
  183. min-width: 0;
  184. ${p => p.theme.buttonPadding[p.size]}
  185. font-weight: 400;
  186. ${p =>
  187. !p.isDisabled &&
  188. `
  189. &:hover {
  190. background-color: inherit;
  191. [role='separator'] {
  192. opacity: 0;
  193. }
  194. }
  195. `}
  196. ${p => p.isSelected && `z-index: 1;`}
  197. `;
  198. const SegmentInput = styled('input')`
  199. appearance: none;
  200. position: absolute;
  201. top: 0;
  202. left: 0;
  203. bottom: 0;
  204. right: 0;
  205. border-radius: ${p => p.theme.borderRadius};
  206. transition: box-shadow 0.125s ease-out;
  207. z-index: -1;
  208. /* Reset global styles */
  209. && {
  210. padding: 0;
  211. margin: 0;
  212. }
  213. &:focus {
  214. outline: none;
  215. }
  216. `;
  217. const SegmentInteractionStateLayer = styled(InteractionStateLayer)<{
  218. nextOptionIsSelected: boolean;
  219. prevOptionIsSelected: boolean;
  220. }>`
  221. top: 0;
  222. left: 0;
  223. bottom: 0;
  224. right: 0;
  225. width: auto;
  226. height: auto;
  227. transform: none;
  228. /* Prevent small gaps between adjacent pairs of selected & hovered radios (due to their
  229. border radius) by extending the hovered radio's interaction state layer into and
  230. behind the selected radio. */
  231. transition: left 0.2s, right 0.2s;
  232. ${p => p.prevOptionIsSelected && `left: calc(-${p.theme.borderRadius} - 2px);`}
  233. ${p => p.nextOptionIsSelected && `right: calc(-${p.theme.borderRadius} - 2px);`}
  234. `;
  235. const SegmentSelectionIndicator = styled(motion.div)<{priority: Priority}>`
  236. position: absolute;
  237. top: 0;
  238. bottom: 0;
  239. left: 0;
  240. right: 0;
  241. background: ${p =>
  242. p.priority === 'primary' ? p.theme.active : p.theme.backgroundElevated};
  243. border-radius: ${p =>
  244. p.priority === 'primary'
  245. ? p.theme.borderRadius
  246. : `calc(${p.theme.borderRadius} - 1px)`};
  247. box-shadow: 0 0 2px rgba(43, 34, 51, 0.32);
  248. input.focus-visible ~ & {
  249. box-shadow: ${p =>
  250. p.priority === 'primary'
  251. ? `0 0 0 3px ${p.theme.focus}`
  252. : `0 0 0 2px ${p.theme.focusBorder}`};
  253. }
  254. ${p =>
  255. p.priority === 'primary' &&
  256. `
  257. top: -1px;
  258. bottom: -1px;
  259. label:first-child > & {
  260. left: -1px;
  261. }
  262. label:last-child > & {
  263. right: -1px;
  264. }
  265. `}
  266. `;
  267. const LabelWrap = styled('span')`
  268. position: relative;
  269. display: flex;
  270. line-height: 1;
  271. `;
  272. const HiddenLabel = styled('span')`
  273. display: inline-block;
  274. margin: 0 2px;
  275. visibility: hidden;
  276. user-select: none;
  277. ${p => p.theme.overflowEllipsis}
  278. `;
  279. function getTextColor({
  280. isDisabled,
  281. isSelected,
  282. priority,
  283. theme,
  284. }: {
  285. isSelected: boolean;
  286. priority: Priority;
  287. theme: Theme;
  288. isDisabled?: boolean;
  289. }) {
  290. if (isDisabled) {
  291. return `color: ${theme.subText};`;
  292. }
  293. if (isSelected) {
  294. return priority === 'primary'
  295. ? `color: ${theme.white};`
  296. : `color: ${theme.headingColor};`;
  297. }
  298. return `color: ${theme.textColor};`;
  299. }
  300. const VisibleLabel = styled('span')<{
  301. isSelected: boolean;
  302. priority: Priority;
  303. isDisabled?: boolean;
  304. }>`
  305. position: absolute;
  306. top: 50%;
  307. left: 50%;
  308. width: max-content;
  309. transform: translate(-50%, -50%);
  310. transition: color 0.25s ease-out;
  311. user-select: none;
  312. font-weight: ${p => (p.isSelected ? 600 : 400)};
  313. letter-spacing: ${p => (p.isSelected ? '-0.015em' : 'inherit')};
  314. text-align: center;
  315. line-height: ${p => p.theme.text.lineHeightBody};
  316. ${getTextColor}
  317. ${p => p.theme.overflowEllipsis}
  318. `;
  319. const Divider = styled('div')<{visible: boolean}>`
  320. position: absolute;
  321. top: 50%;
  322. right: 0;
  323. width: 0;
  324. height: 50%;
  325. transform: translate(1px, -50%);
  326. border-right: solid 1px ${p => p.theme.innerBorder};
  327. label:last-child > & {
  328. display: none;
  329. }
  330. ${p => !p.visible && `opacity: 0;`}
  331. `;