menuListItem.tsx 8.8 KB

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