menuItem.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import omit from 'lodash/omit';
  4. import Link, {LinkProps} from 'sentry/components/links/link';
  5. import space from 'sentry/styles/space';
  6. import {callIfFunction} from 'sentry/utils/callIfFunction';
  7. import {Theme} from 'sentry/utils/theme';
  8. type MenuItemProps = {
  9. /**
  10. * Enable to allow default event on click
  11. */
  12. allowDefaultEvent?: boolean;
  13. 'aria-label'?: string;
  14. className?: string;
  15. /**
  16. * Is the item disabled?
  17. */
  18. disabled?: boolean;
  19. /**
  20. * Should this item act as a divider
  21. */
  22. divider?: boolean;
  23. /**
  24. * Provided to the onSelect callback when this item is selected
  25. */
  26. eventKey?: any;
  27. /**
  28. * Should this item act as a header
  29. */
  30. header?: boolean;
  31. /**
  32. * A server rendered URL.
  33. */
  34. href?: string;
  35. /**
  36. * Renders an icon next to the item
  37. */
  38. icon?: React.ReactNode;
  39. /**
  40. * Is the item actively seleted?
  41. */
  42. isActive?: boolean;
  43. /**
  44. * Enable to provide custom button/contents via children
  45. */
  46. noAnchor?: boolean;
  47. /**
  48. * Triggered when the item is clicked
  49. */
  50. onSelect?: (eventKey: any) => void;
  51. /**
  52. * Enable to stop event propagation on click
  53. */
  54. stopPropagation?: boolean;
  55. /**
  56. * The title/tooltipe of the item
  57. */
  58. title?: string;
  59. /**
  60. * A router target destination
  61. */
  62. to?: LinkProps['to'];
  63. /**
  64. * Renders a bottom border (excludes the last item)
  65. */
  66. withBorder?: boolean;
  67. };
  68. interface Props
  69. extends MenuItemProps,
  70. Omit<React.HTMLAttributes<HTMLLIElement>, 'onSelect'> {}
  71. const MenuItem = ({
  72. header,
  73. icon,
  74. divider,
  75. isActive,
  76. noAnchor,
  77. className,
  78. children,
  79. ...props
  80. }: Props) => {
  81. const {
  82. to,
  83. href,
  84. title,
  85. withBorder,
  86. disabled,
  87. onSelect,
  88. eventKey,
  89. allowDefaultEvent,
  90. stopPropagation,
  91. } = props;
  92. const handleClick = (e: React.MouseEvent): void => {
  93. if (disabled) {
  94. return;
  95. }
  96. if (onSelect) {
  97. if (allowDefaultEvent !== true) {
  98. e.preventDefault();
  99. }
  100. if (stopPropagation) {
  101. e.stopPropagation();
  102. }
  103. callIfFunction(onSelect, eventKey);
  104. }
  105. };
  106. const renderAnchor = (): React.ReactNode => {
  107. const linkProps = {
  108. onClick: handleClick,
  109. tabIndex: -1,
  110. isActive,
  111. disabled,
  112. withBorder,
  113. };
  114. if (to) {
  115. return (
  116. <MenuLink to={to} {...linkProps} title={title} data-test-id="menu-item">
  117. {icon && <MenuIcon>{icon}</MenuIcon>}
  118. {children}
  119. </MenuLink>
  120. );
  121. }
  122. if (href) {
  123. return (
  124. <MenuAnchor {...linkProps} href={href} data-test-id="menu-item">
  125. {icon && <MenuIcon>{icon}</MenuIcon>}
  126. {children}
  127. </MenuAnchor>
  128. );
  129. }
  130. return (
  131. <MenuTarget role="button" {...linkProps} title={title} data-test-id="menu-item">
  132. {icon && <MenuIcon>{icon}</MenuIcon>}
  133. {children}
  134. </MenuTarget>
  135. );
  136. };
  137. let renderChildren: React.ReactNode | null = null;
  138. if (noAnchor) {
  139. renderChildren = children;
  140. } else if (header) {
  141. renderChildren = children;
  142. } else if (!divider) {
  143. renderChildren = renderAnchor();
  144. }
  145. return (
  146. <MenuListItem
  147. className={className}
  148. role="presentation"
  149. isActive={isActive}
  150. divider={divider}
  151. noAnchor={noAnchor}
  152. header={header}
  153. {...omit(props, ['href', 'title', 'onSelect', 'eventKey', 'to', 'as'])}
  154. >
  155. {renderChildren}
  156. </MenuListItem>
  157. );
  158. };
  159. interface MenuListItemProps extends React.HTMLAttributes<HTMLLIElement> {
  160. disabled?: boolean;
  161. divider?: boolean;
  162. header?: boolean;
  163. isActive?: boolean;
  164. noAnchor?: boolean;
  165. withBorder?: boolean;
  166. }
  167. function getListItemStyles(props: MenuListItemProps & {theme: Theme}) {
  168. const common = `
  169. display: block;
  170. padding: ${space(0.5)} ${space(2)};
  171. &:focus {
  172. outline: none;
  173. }
  174. `;
  175. if (props.disabled) {
  176. return `
  177. ${common}
  178. color: ${props.theme.disabled};
  179. background: transparent;
  180. cursor: not-allowed;
  181. `;
  182. }
  183. if (props.isActive) {
  184. return `
  185. ${common}
  186. color: ${props.theme.white};
  187. background: ${props.theme.active};
  188. &:hover {
  189. background: ${props.theme.activeHover};
  190. }
  191. `;
  192. }
  193. return `
  194. ${common}
  195. &:hover {
  196. background: ${props.theme.hover};
  197. }
  198. `;
  199. }
  200. function getChildStyles(props: MenuListItemProps & {theme: Theme}) {
  201. if (!props.noAnchor) {
  202. return '';
  203. }
  204. return `
  205. & a {
  206. ${getListItemStyles(props)}
  207. }
  208. `;
  209. }
  210. const shouldForwardProp = (p: PropertyKey) =>
  211. typeof p === 'string' && ['isActive', 'disabled', 'withBorder'].includes(p) === false;
  212. const MenuAnchor = styled('a', {shouldForwardProp})<MenuListItemProps>`
  213. ${getListItemStyles}
  214. `;
  215. const MenuListItem = styled('li')<MenuListItemProps>`
  216. display: block;
  217. ${p =>
  218. p.withBorder &&
  219. `
  220. border-bottom: 1px solid ${p.theme.innerBorder};
  221. &:last-child {
  222. border-bottom: none;
  223. }
  224. `};
  225. ${p =>
  226. p.divider &&
  227. `
  228. height: 1px;
  229. margin: ${space(0.5)} 0;
  230. overflow: hidden;
  231. background-color: ${p.theme.innerBorder};
  232. `}
  233. ${p =>
  234. p.header &&
  235. `
  236. padding: ${space(0.25)} ${space(0.5)};
  237. font-size: ${p.theme.fontSizeSmall};
  238. line-height: 1.4;
  239. color: ${p.theme.gray300};
  240. `}
  241. ${getChildStyles}
  242. `;
  243. const MenuTarget = styled('span')<MenuListItemProps>`
  244. ${getListItemStyles}
  245. display: flex;
  246. align-items: center;
  247. `;
  248. const MenuIcon = styled('div')`
  249. display: flex;
  250. align-items: center;
  251. margin-right: ${space(1)};
  252. `;
  253. const MenuLink = styled(Link, {shouldForwardProp})<MenuListItemProps>`
  254. ${getListItemStyles}
  255. `;
  256. export default MenuItem;