menuListItem.tsx 8.4 KB

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