menuItem.tsx 5.5 KB

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