segmentedControl.tsx 11 KB

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