menuItem.tsx 5.5 KB

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