menuListItem.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  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. type OtherProps = {
  72. as?: React.ElementType;
  73. detailsProps?: object;
  74. innerWrapProps?: object;
  75. isFocused?: boolean;
  76. labelProps?: object;
  77. };
  78. type Props = MenuListItemProps &
  79. 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. return (
  104. <MenuItemWrap as={as} ref={forwardRef} {...props}>
  105. <Tooltip skipWrapper title={tooltip} {...tooltipOptions}>
  106. <InnerWrap
  107. isFocused={isFocused}
  108. disabled={disabled}
  109. priority={priority}
  110. size={size}
  111. {...innerWrapProps}
  112. >
  113. {leadingItems && (
  114. <LeadingItems
  115. disabled={disabled}
  116. spanFullHeight={leadingItemsSpanFullHeight}
  117. size={size}
  118. >
  119. {leadingItems}
  120. </LeadingItems>
  121. )}
  122. <ContentWrap
  123. isFocused={isFocused}
  124. showDivider={defined(details) || showDivider}
  125. size={size}
  126. >
  127. <LabelWrap>
  128. <Label aria-hidden="true" {...labelProps}>
  129. {label}
  130. </Label>
  131. {details && (
  132. <Details disabled={disabled} priority={priority} {...detailsProps}>
  133. {details}
  134. </Details>
  135. )}
  136. </LabelWrap>
  137. {trailingItems && (
  138. <TrailingItems
  139. disabled={disabled}
  140. spanFullHeight={trailingItemsSpanFullHeight}
  141. >
  142. {trailingItems}
  143. </TrailingItems>
  144. )}
  145. </ContentWrap>
  146. </InnerWrap>
  147. </Tooltip>
  148. </MenuItemWrap>
  149. );
  150. }
  151. const MenuListItem = reactForwardRef<HTMLLIElement, MenuListItemProps & OtherProps>(
  152. (props, ref) => <BaseMenuListItem {...props} forwardRef={ref} />
  153. );
  154. export default MenuListItem;
  155. const MenuItemWrap = styled('li')`
  156. position: static;
  157. list-style-type: none;
  158. margin: 0;
  159. padding: 0 ${space(0.5)};
  160. cursor: pointer;
  161. &:focus {
  162. outline: none;
  163. }
  164. &:focus-visible {
  165. outline: none;
  166. }
  167. `;
  168. function getTextColor({
  169. theme,
  170. priority,
  171. disabled,
  172. }: {
  173. disabled: boolean;
  174. priority: Priority;
  175. theme: Theme;
  176. }) {
  177. if (disabled) {
  178. return theme.subText;
  179. }
  180. switch (priority) {
  181. case 'primary':
  182. return theme.activeText;
  183. case 'danger':
  184. return theme.errorText;
  185. case 'default':
  186. default:
  187. return theme.textColor;
  188. }
  189. }
  190. function getFocusBackground({theme, priority}: {priority: Priority; theme: Theme}) {
  191. switch (priority) {
  192. case 'primary':
  193. return theme.purple100;
  194. case 'danger':
  195. return theme.red100;
  196. case 'default':
  197. default:
  198. return theme.hover;
  199. }
  200. }
  201. export const InnerWrap = styled('div', {
  202. shouldForwardProp: prop =>
  203. typeof prop === 'string' &&
  204. isPropValid(prop) &&
  205. !['disabled', 'isFocused', 'priority'].includes(prop),
  206. })<{
  207. disabled: boolean;
  208. isFocused: boolean;
  209. priority: Priority;
  210. size: Props['size'];
  211. }>`
  212. display: flex;
  213. position: relative;
  214. padding: 0 ${space(1)} 0 ${space(1.5)};
  215. border-radius: ${p => p.theme.borderRadius};
  216. box-sizing: border-box;
  217. font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
  218. &,
  219. &:hover {
  220. color: ${getTextColor};
  221. }
  222. ${p => p.disabled && `cursor: initial;`}
  223. ${p =>
  224. p.isFocused &&
  225. `
  226. z-index: 1;
  227. ::before, ::after {
  228. content: '';
  229. position: absolute;
  230. top: 0;
  231. left: 0;
  232. width: 100%;
  233. height: 100%;
  234. }
  235. /* Background to hide the previous item's divider */
  236. ::before {
  237. background: ${p.theme.background};
  238. z-index: -1;
  239. }
  240. /* Hover/focus background */
  241. ::after {
  242. background: ${getFocusBackground(p)};
  243. border-radius: inherit;
  244. z-index: -1;
  245. }
  246. `}
  247. `;
  248. /**
  249. * Returns the appropriate vertical padding based on the size prop. To be used
  250. * as top/bottom padding/margin in ContentWrap and LeadingItems.
  251. */
  252. const getVerticalPadding = (size: Props['size']) => {
  253. switch (size) {
  254. case 'xs':
  255. return space(0.5);
  256. case 'sm':
  257. return space(0.75);
  258. case 'md':
  259. default:
  260. return space(1);
  261. }
  262. };
  263. const ContentWrap = styled('div')<{
  264. isFocused: boolean;
  265. showDivider: boolean;
  266. size: Props['size'];
  267. }>`
  268. position: relative;
  269. width: 100%;
  270. min-width: 0;
  271. display: flex;
  272. gap: ${space(1)};
  273. justify-content: space-between;
  274. padding: ${p => getVerticalPadding(p.size)} 0;
  275. ${p =>
  276. p.showDivider &&
  277. !p.isFocused &&
  278. `
  279. ${MenuItemWrap}:not(:last-child) &::after {
  280. content: '';
  281. position: absolute;
  282. left: 0;
  283. bottom: 0;
  284. width: 100%;
  285. height: 1px;
  286. box-shadow: 0 1px 0 0 ${p.theme.innerBorder};
  287. }
  288. `}
  289. `;
  290. const LeadingItems = styled('div')<{
  291. disabled: boolean;
  292. size: Props['size'];
  293. spanFullHeight: boolean;
  294. }>`
  295. display: flex;
  296. align-items: center;
  297. height: 1.4em;
  298. gap: ${space(1)};
  299. margin-top: ${p => getVerticalPadding(p.size)};
  300. margin-right: ${space(1)};
  301. ${p => p.disabled && `opacity: 0.5;`}
  302. ${p => p.spanFullHeight && `height: 100%;`}
  303. `;
  304. const LabelWrap = styled('div')`
  305. padding-right: ${space(1)};
  306. width: 100%;
  307. min-width: 0;
  308. `;
  309. const Label = styled('p')`
  310. margin-bottom: 0;
  311. line-height: 1.4;
  312. white-space: nowrap;
  313. ${p => p.theme.overflowEllipsis}
  314. `;
  315. const Details = styled('p')<{disabled: boolean; priority: Priority}>`
  316. font-size: ${p => p.theme.fontSizeSmall};
  317. color: ${p => p.theme.subText};
  318. line-height: 1.2;
  319. margin-bottom: 0;
  320. ${p => p.theme.overflowEllipsis}
  321. ${p => p.priority !== 'default' && `color: ${getTextColor(p)};`}
  322. `;
  323. const TrailingItems = styled('div')<{disabled: boolean; spanFullHeight: boolean}>`
  324. display: flex;
  325. align-items: center;
  326. height: 1.4em;
  327. gap: ${space(1)};
  328. margin-right: ${space(0.5)};
  329. ${p => p.disabled && `opacity: 0.5;`}
  330. ${p => p.spanFullHeight && `height: 100%;`}
  331. `;