menuListItem.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import {forwardRef as reactForwardRef, memo, useMemo} from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import {Theme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  6. import {Tooltip, TooltipProps} from 'sentry/components/tooltip';
  7. import {space} from 'sentry/styles/space';
  8. import domId from 'sentry/utils/domId';
  9. import {FormSize} from 'sentry/utils/theme';
  10. /**
  11. * Menu item priority. Determines the text and background color.
  12. */
  13. type Priority = 'primary' | 'danger' | 'default';
  14. /**
  15. * Leading/trailing items to be rendered alongside the main text label.
  16. */
  17. type EdgeItems =
  18. | React.ReactNode
  19. | ((state: {
  20. disabled: boolean;
  21. isFocused: boolean;
  22. isSelected: boolean;
  23. }) => React.ReactNode);
  24. export type MenuListItemProps = {
  25. /**
  26. * Optional descriptive text. Like 'label', should preferably be a string or
  27. * have appropriate aria-labels.
  28. */
  29. details?: React.ReactNode;
  30. /**
  31. * Whether the item is disabled (if true, the item will be grayed out and
  32. * non-interactive).
  33. */
  34. disabled?: boolean;
  35. /**
  36. * Item label. Should preferably be a string. If not, make sure that
  37. * there are appropriate aria-labels.
  38. */
  39. label?: React.ReactNode;
  40. /**
  41. * Items to be added to the left of the label
  42. */
  43. leadingItems?: EdgeItems;
  44. /**
  45. * Whether leading items should be centered with respect to the entire height
  46. * of the item. If false (default), they will be centered with respect to the
  47. * first line of the label element.
  48. */
  49. leadingItemsSpanFullHeight?: boolean;
  50. /**
  51. * Accented text and background (on hover) colors.
  52. */
  53. priority?: Priority;
  54. /**
  55. * Determines the item's font sizes and internal paddings.
  56. */
  57. size?: FormSize;
  58. /**
  59. * Optional tooltip that appears when the use hovers over the item. This is
  60. * not very visible - if possible, add additional text via the `details`
  61. * prop instead.
  62. */
  63. tooltip?: React.ReactNode;
  64. /**
  65. * Additional props to be passed into <Tooltip />.
  66. */
  67. tooltipOptions?: Omit<TooltipProps, 'children' | 'title' | 'className'>;
  68. /**
  69. * Items to be added to the right of the label.
  70. */
  71. trailingItems?: EdgeItems;
  72. /**
  73. * Whether trailing items should be centered wrt/ the entire height of the
  74. * item. If false (default), they will be centered wrt/ the first line of the
  75. * label element.
  76. */
  77. trailingItemsSpanFullHeight?: boolean;
  78. };
  79. interface OtherProps {
  80. as?: React.ElementType;
  81. detailsProps?: object;
  82. innerWrapProps?: object;
  83. isFocused?: boolean;
  84. isPressed?: boolean;
  85. isSelected?: boolean;
  86. labelProps?: object;
  87. showDivider?: boolean;
  88. }
  89. interface Props extends MenuListItemProps, OtherProps {
  90. forwardRef: React.ForwardedRef<HTMLLIElement>;
  91. }
  92. function BaseMenuListItem({
  93. label,
  94. details,
  95. as = 'li',
  96. priority = 'default',
  97. size,
  98. disabled = false,
  99. showDivider = false,
  100. leadingItems = false,
  101. leadingItemsSpanFullHeight = false,
  102. trailingItems = false,
  103. trailingItemsSpanFullHeight = false,
  104. isFocused = false,
  105. isSelected = false,
  106. isPressed,
  107. innerWrapProps = {},
  108. labelProps = {},
  109. detailsProps = {},
  110. tooltip,
  111. tooltipOptions = {delay: 500},
  112. forwardRef,
  113. ...props
  114. }: Props) {
  115. const labelId = useMemo(() => domId('menuitem-label-'), []);
  116. const detailId = useMemo(() => domId('menuitem-details-'), []);
  117. return (
  118. <MenuItemWrap
  119. role="menuitem"
  120. aria-disabled={disabled}
  121. aria-labelledby={labelId}
  122. aria-describedby={detailId}
  123. as={as}
  124. ref={forwardRef}
  125. {...props}
  126. >
  127. <Tooltip skipWrapper title={tooltip} {...tooltipOptions}>
  128. <InnerWrap
  129. isFocused={isFocused}
  130. disabled={disabled}
  131. priority={priority}
  132. size={size}
  133. {...innerWrapProps}
  134. >
  135. <StyledInteractionStateLayer
  136. isHovered={isFocused}
  137. isPressed={isPressed}
  138. higherOpacity={priority !== 'default'}
  139. />
  140. {leadingItems && (
  141. <LeadingItems
  142. disabled={disabled}
  143. spanFullHeight={leadingItemsSpanFullHeight}
  144. size={size}
  145. >
  146. {typeof leadingItems === 'function'
  147. ? leadingItems({disabled, isFocused, isSelected})
  148. : leadingItems}
  149. </LeadingItems>
  150. )}
  151. <ContentWrap isFocused={isFocused} showDivider={showDivider} size={size}>
  152. <LabelWrap>
  153. <Label
  154. id={labelId}
  155. data-test-id="menu-list-item-label"
  156. aria-hidden="true"
  157. {...labelProps}
  158. >
  159. {label}
  160. </Label>
  161. {details && (
  162. <Details
  163. id={detailId}
  164. disabled={disabled}
  165. priority={priority}
  166. {...detailsProps}
  167. >
  168. {details}
  169. </Details>
  170. )}
  171. </LabelWrap>
  172. {trailingItems && (
  173. <TrailingItems
  174. disabled={disabled}
  175. spanFullHeight={trailingItemsSpanFullHeight}
  176. >
  177. {typeof trailingItems === 'function'
  178. ? trailingItems({disabled, isFocused, isSelected})
  179. : trailingItems}
  180. </TrailingItems>
  181. )}
  182. </ContentWrap>
  183. </InnerWrap>
  184. </Tooltip>
  185. </MenuItemWrap>
  186. );
  187. }
  188. const MenuListItem = memo(
  189. reactForwardRef<HTMLLIElement, MenuListItemProps & OtherProps>((props, ref) => (
  190. <BaseMenuListItem {...props} forwardRef={ref} />
  191. ))
  192. );
  193. export default MenuListItem;
  194. const MenuItemWrap = styled('li')`
  195. position: static;
  196. list-style-type: none;
  197. margin: 0;
  198. padding: 0 ${space(0.5)};
  199. cursor: pointer;
  200. &:focus {
  201. outline: none;
  202. }
  203. &:focus-visible {
  204. outline: none;
  205. }
  206. `;
  207. function getTextColor({
  208. theme,
  209. priority,
  210. disabled,
  211. }: {
  212. disabled: boolean;
  213. priority: Priority;
  214. theme: Theme;
  215. }) {
  216. if (disabled) {
  217. return theme.subText;
  218. }
  219. switch (priority) {
  220. case 'primary':
  221. return theme.activeText;
  222. case 'danger':
  223. return theme.errorText;
  224. case 'default':
  225. default:
  226. return theme.textColor;
  227. }
  228. }
  229. export const InnerWrap = styled('div', {
  230. shouldForwardProp: prop =>
  231. typeof prop === 'string' &&
  232. isPropValid(prop) &&
  233. !['disabled', 'isFocused', 'priority'].includes(prop),
  234. })<{
  235. disabled: boolean;
  236. isFocused: boolean;
  237. priority: Priority;
  238. size: Props['size'];
  239. }>`
  240. display: flex;
  241. position: relative;
  242. padding: 0 ${space(1)} 0 ${space(1.5)};
  243. border-radius: ${p => p.theme.borderRadius};
  244. box-sizing: border-box;
  245. font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
  246. &,
  247. &:hover {
  248. color: ${getTextColor};
  249. }
  250. ${p => p.disabled && `cursor: default;`}
  251. &::before {
  252. content: '';
  253. position: absolute;
  254. top: 0;
  255. left: 0;
  256. width: 100%;
  257. height: 100%;
  258. z-index: -1;
  259. }
  260. ${p =>
  261. p.isFocused &&
  262. `
  263. z-index: 1;
  264. /* Background to hide the previous item's divider */
  265. ::before {
  266. background: ${p.theme.backgroundElevated};
  267. }
  268. `}
  269. `;
  270. const StyledInteractionStateLayer = styled(InteractionStateLayer)`
  271. z-index: -1;
  272. `;
  273. /**
  274. * Returns the appropriate vertical padding based on the size prop. To be used
  275. * as top/bottom padding/margin in ContentWrap and LeadingItems.
  276. */
  277. const getVerticalPadding = (size: Props['size']) => {
  278. switch (size) {
  279. case 'xs':
  280. return space(0.5);
  281. case 'sm':
  282. return space(0.75);
  283. case 'md':
  284. default:
  285. return space(1);
  286. }
  287. };
  288. const ContentWrap = styled('div')<{
  289. isFocused: boolean;
  290. showDivider: boolean;
  291. size: Props['size'];
  292. }>`
  293. position: relative;
  294. width: 100%;
  295. min-width: 0;
  296. display: flex;
  297. gap: ${space(1)};
  298. justify-content: space-between;
  299. padding: ${p => getVerticalPadding(p.size)} 0;
  300. ${p =>
  301. p.showDivider &&
  302. !p.isFocused &&
  303. `
  304. li:not(:last-child) &::after {
  305. content: '';
  306. position: absolute;
  307. left: 0;
  308. bottom: 0;
  309. width: 100%;
  310. height: 1px;
  311. box-shadow: 0 1px 0 0 ${p.theme.innerBorder};
  312. }
  313. `}
  314. `;
  315. const LeadingItems = styled('div')<{
  316. disabled: boolean;
  317. size: Props['size'];
  318. spanFullHeight: boolean;
  319. }>`
  320. display: flex;
  321. align-items: center;
  322. height: 1.4em;
  323. gap: ${space(1)};
  324. margin-top: ${p => getVerticalPadding(p.size)};
  325. margin-right: ${space(1)};
  326. ${p => p.disabled && `opacity: 0.5;`}
  327. ${p => p.spanFullHeight && `height: 100%;`}
  328. `;
  329. const LabelWrap = styled('div')`
  330. padding-right: ${space(1)};
  331. width: 100%;
  332. min-width: 0;
  333. `;
  334. const Label = styled('p')`
  335. margin-bottom: 0;
  336. line-height: 1.4;
  337. white-space: nowrap;
  338. ${p => p.theme.overflowEllipsis}
  339. `;
  340. const Details = styled('p')<{disabled: boolean; priority: Priority}>`
  341. font-size: ${p => p.theme.fontSizeSmall};
  342. color: ${p => p.theme.subText};
  343. line-height: 1.2;
  344. margin-bottom: 0;
  345. ${p => p.priority !== 'default' && `color: ${getTextColor(p)};`}
  346. `;
  347. const TrailingItems = styled('div')<{disabled: boolean; spanFullHeight: boolean}>`
  348. display: flex;
  349. align-items: center;
  350. height: 1.4em;
  351. gap: ${space(1)};
  352. margin-right: ${space(0.5)};
  353. ${p => p.disabled && `opacity: 0.5;`}
  354. ${p => p.spanFullHeight && `height: 100%;`}
  355. `;