sidebarItem.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import FeatureBadge from 'sentry/components/featureBadge';
  6. import HookOrDefault from 'sentry/components/hookOrDefault';
  7. import Link from 'sentry/components/links/link';
  8. import TextOverflow from 'sentry/components/textOverflow';
  9. import Tooltip from 'sentry/components/tooltip';
  10. import localStorage from 'sentry/utils/localStorage';
  11. import {Theme} from 'sentry/utils/theme';
  12. import {SidebarOrientation} from './types';
  13. const LabelHook = HookOrDefault({
  14. hookName: 'sidebar:item-label',
  15. defaultComponent: ({children}) => <React.Fragment>{children}</React.Fragment>,
  16. });
  17. type Props = WithRouterProps & {
  18. onClick?: (id: string, e: React.MouseEvent<HTMLAnchorElement>) => void;
  19. className?: string;
  20. index?: boolean;
  21. href?: string;
  22. to?: string;
  23. /**
  24. * Key of the sidebar item. Used for label hooks
  25. */
  26. id: string;
  27. /**
  28. * Is this sidebar item active
  29. */
  30. active?: boolean;
  31. /**
  32. * Is sidebar in a collapsed state
  33. */
  34. collapsed?: boolean;
  35. /**
  36. * Sidebar has a panel open
  37. */
  38. hasPanel?: boolean;
  39. /**
  40. * Icon to display
  41. */
  42. icon: React.ReactNode;
  43. /**
  44. * Label to display (only when expanded)
  45. */
  46. label: React.ReactNode;
  47. /**
  48. * Additional badge to display after label
  49. */
  50. badge?: number;
  51. /**
  52. * Additional badge letting users know a tab is new.
  53. */
  54. isNew?: boolean;
  55. /**
  56. * Additional badge letting users know a tab is in beta.
  57. */
  58. isBeta?: boolean;
  59. /**
  60. * Sidebar is at "top" or "left" of screen
  61. */
  62. orientation: SidebarOrientation;
  63. /**
  64. * An optional prefix that can be used to reset the "new" indicator
  65. */
  66. isNewSeenKeySuffix?: string;
  67. };
  68. const SidebarItem = ({
  69. router,
  70. id,
  71. href,
  72. to,
  73. icon,
  74. label,
  75. badge,
  76. active,
  77. hasPanel,
  78. isNew,
  79. isBeta,
  80. collapsed,
  81. className,
  82. orientation,
  83. isNewSeenKeySuffix,
  84. onClick,
  85. ...props
  86. }: Props) => {
  87. // label might be wrapped in a guideAnchor
  88. let labelString = label;
  89. if (React.isValidElement(label)) {
  90. labelString = label?.props?.children ?? label;
  91. }
  92. // If there is no active panel open and if path is active according to react-router
  93. const isActiveRouter =
  94. (!hasPanel && router && to && location.pathname.startsWith(to)) ||
  95. (labelString === 'Discover' && location.pathname.includes('/discover/')) ||
  96. (labelString === 'Dashboards' &&
  97. (location.pathname.includes('/dashboards/') ||
  98. location.pathname.includes('/dashboard/'))) ||
  99. // TODO: this won't be necessary once we remove settingsHome
  100. (labelString === 'Settings' && location.pathname.startsWith('/settings/')) ||
  101. (labelString === 'Alerts' &&
  102. location.pathname.includes('/alerts/') &&
  103. !location.pathname.startsWith('/settings/'));
  104. const isActive = active || isActiveRouter;
  105. const isTop = orientation === 'top';
  106. const placement = isTop ? 'bottom' : 'right';
  107. const seenSuffix = isNewSeenKeySuffix ?? '';
  108. const isNewSeenKey = `sidebar-new-seen:${id}${seenSuffix}`;
  109. const showIsNew = isNew && !localStorage.getItem(isNewSeenKey);
  110. return (
  111. <Tooltip disabled={!collapsed} title={label} position={placement}>
  112. <StyledSidebarItem
  113. data-test-id={props['data-test-id']}
  114. id={`sidebar-item-${id}`}
  115. active={isActive ? 'true' : undefined}
  116. to={(to ? to : href) || '#'}
  117. className={className}
  118. onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
  119. !(to || href) && event.preventDefault();
  120. onClick?.(id, event);
  121. showIsNew && localStorage.setItem(isNewSeenKey, 'true');
  122. }}
  123. >
  124. <SidebarItemWrapper>
  125. <SidebarItemIcon>{icon}</SidebarItemIcon>
  126. {!collapsed && !isTop && (
  127. <SidebarItemLabel>
  128. <LabelHook id={id}>
  129. <TextOverflow>{label}</TextOverflow>
  130. {showIsNew && <FeatureBadge type="new" noTooltip />}
  131. {isBeta && <FeatureBadge type="beta" noTooltip />}
  132. </LabelHook>
  133. </SidebarItemLabel>
  134. )}
  135. {collapsed && showIsNew && <CollapsedFeatureBadge type="new" />}
  136. {collapsed && isBeta && <CollapsedFeatureBadge type="beta" />}
  137. {badge !== undefined && badge > 0 && (
  138. <SidebarItemBadge collapsed={collapsed}>{badge}</SidebarItemBadge>
  139. )}
  140. </SidebarItemWrapper>
  141. </StyledSidebarItem>
  142. </Tooltip>
  143. );
  144. };
  145. export default withRouter(SidebarItem);
  146. const getActiveStyle = ({active, theme}: {active?: string; theme?: Theme}) => {
  147. if (!active) {
  148. return '';
  149. }
  150. return css`
  151. color: ${theme?.white};
  152. &:active,
  153. &:focus,
  154. &:hover {
  155. color: ${theme?.white};
  156. }
  157. &:before {
  158. background-color: ${theme?.active};
  159. }
  160. `;
  161. };
  162. const StyledSidebarItem = styled(Link)`
  163. display: flex;
  164. color: inherit;
  165. position: relative;
  166. cursor: pointer;
  167. font-size: 15px;
  168. line-height: 32px;
  169. height: 34px;
  170. flex-shrink: 0;
  171. transition: 0.15s color linear;
  172. &:before {
  173. display: block;
  174. content: '';
  175. position: absolute;
  176. top: 4px;
  177. left: -20px;
  178. bottom: 6px;
  179. width: 5px;
  180. border-radius: 0 3px 3px 0;
  181. background-color: transparent;
  182. transition: 0.15s background-color linear;
  183. }
  184. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  185. margin: 0 4px;
  186. &:before {
  187. top: auto;
  188. left: 5px;
  189. bottom: -10px;
  190. height: 5px;
  191. width: auto;
  192. right: 5px;
  193. border-radius: 3px 3px 0 0;
  194. }
  195. }
  196. &:hover,
  197. &:focus {
  198. color: ${p => p.theme.white};
  199. }
  200. &.focus-visible {
  201. outline: none;
  202. background: #584c66;
  203. padding: 0 19px;
  204. margin: 0 -19px;
  205. &:before {
  206. left: 0;
  207. }
  208. }
  209. ${getActiveStyle};
  210. `;
  211. const SidebarItemWrapper = styled('div')`
  212. display: flex;
  213. align-items: center;
  214. width: 100%;
  215. `;
  216. const SidebarItemIcon = styled('span')`
  217. content: '';
  218. display: inline-flex;
  219. width: 32px;
  220. height: 22px;
  221. font-size: 20px;
  222. align-items: center;
  223. flex-shrink: 0;
  224. svg {
  225. display: block;
  226. margin: 0 auto;
  227. }
  228. `;
  229. const SidebarItemLabel = styled('span')`
  230. margin-left: 12px;
  231. white-space: nowrap;
  232. opacity: 1;
  233. flex: 1;
  234. display: flex;
  235. align-items: center;
  236. justify-content: space-between;
  237. `;
  238. const getCollapsedBadgeStyle = ({collapsed, theme}) => {
  239. if (!collapsed) {
  240. return '';
  241. }
  242. return css`
  243. text-indent: -99999em;
  244. position: absolute;
  245. right: 0;
  246. top: 1px;
  247. background: ${theme.red300};
  248. width: ${theme.sidebar.smallBadgeSize};
  249. height: ${theme.sidebar.smallBadgeSize};
  250. border-radius: ${theme.sidebar.smallBadgeSize};
  251. line-height: ${theme.sidebar.smallBadgeSize};
  252. box-shadow: ${theme.sidebar.boxShadow};
  253. `;
  254. };
  255. const SidebarItemBadge = styled(({collapsed: _, ...props}) => <span {...props} />)`
  256. display: block;
  257. text-align: center;
  258. color: ${p => p.theme.white};
  259. font-size: 12px;
  260. background: ${p => p.theme.red300};
  261. width: ${p => p.theme.sidebar.badgeSize};
  262. height: ${p => p.theme.sidebar.badgeSize};
  263. border-radius: ${p => p.theme.sidebar.badgeSize};
  264. line-height: ${p => p.theme.sidebar.badgeSize};
  265. ${getCollapsedBadgeStyle};
  266. `;
  267. const CollapsedFeatureBadge = styled(FeatureBadge)`
  268. position: absolute;
  269. top: 0;
  270. right: 0;
  271. `;
  272. CollapsedFeatureBadge.defaultProps = {
  273. variant: 'indicator',
  274. noTooltip: true,
  275. };