tag.tsx 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import {cloneElement, isValidElement} from 'react';
  2. import {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {Button} from 'sentry/components/button';
  5. import ExternalLink from 'sentry/components/links/externalLink';
  6. import Link, {LinkProps} from 'sentry/components/links/link';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {IconClose, IconOpen} from 'sentry/icons';
  9. import {SVGIconProps} from 'sentry/icons/svgIcon';
  10. import {t} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import {defined} from 'sentry/utils';
  13. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  14. import theme, {Color} from 'sentry/utils/theme';
  15. const TAG_HEIGHT = '20px';
  16. interface Props extends React.HTMLAttributes<HTMLSpanElement> {
  17. /**
  18. * Makes the tag clickable. Use for external links.
  19. * If no icon is passed, it defaults to IconOpen (can be removed by passing icon={null})
  20. */
  21. href?: string;
  22. /**
  23. * Icon on the left side.
  24. */
  25. icon?: React.ReactNode;
  26. /**
  27. * Triggered when the item is clicked
  28. */
  29. onClick?: (eventKey: any) => void;
  30. /**
  31. * Shows clickable IconClose on the right side.
  32. */
  33. onDismiss?: () => void;
  34. /**
  35. * Max width of the tag's text
  36. */
  37. textMaxWidth?: number;
  38. /**
  39. * Makes the tag clickable. Use for internal links handled by react router.
  40. * If no icon is passed, it defaults to IconOpen (can be removed by passing icon={null})
  41. */
  42. to?: LinkProps['to'];
  43. /**
  44. * Text to show up on a hover.
  45. */
  46. tooltipText?: React.ComponentProps<typeof Tooltip>['title'];
  47. /**
  48. * Dictates color scheme of the tag.
  49. */
  50. type?: keyof Theme['tag'];
  51. }
  52. function Tag({
  53. type = 'default',
  54. icon,
  55. tooltipText,
  56. to,
  57. onClick,
  58. href,
  59. onDismiss,
  60. children,
  61. textMaxWidth = 150,
  62. ...props
  63. }: Props) {
  64. const iconsProps: SVGIconProps = {
  65. size: 'xs',
  66. color: theme.tag[type].iconColor as Color,
  67. };
  68. const tag = (
  69. <Tooltip title={tooltipText} containerDisplayMode="inline-flex">
  70. <Background type={type}>
  71. {tagIcon()}
  72. <Text type={type} maxWidth={textMaxWidth}>
  73. {children}
  74. </Text>
  75. {defined(onDismiss) && (
  76. <DismissButton
  77. onClick={handleDismiss}
  78. size="zero"
  79. priority="link"
  80. aria-label={t('Dismiss')}
  81. >
  82. <IconClose isCircled {...iconsProps} />
  83. </DismissButton>
  84. )}
  85. </Background>
  86. </Tooltip>
  87. );
  88. function handleDismiss(event: React.MouseEvent) {
  89. event.preventDefault();
  90. onDismiss?.();
  91. }
  92. const trackClickEvent = () => {
  93. trackAdvancedAnalyticsEvent('tag.clicked', {
  94. is_clickable: defined(onClick) || defined(to) || defined(href),
  95. organization: null,
  96. });
  97. };
  98. function tagIcon() {
  99. if (isValidElement(icon)) {
  100. return <IconWrapper>{cloneElement(icon, {...iconsProps})}</IconWrapper>;
  101. }
  102. if ((defined(href) || defined(to)) && icon === undefined) {
  103. return (
  104. <IconWrapper>
  105. <IconOpen {...iconsProps} />
  106. </IconWrapper>
  107. );
  108. }
  109. return null;
  110. }
  111. function tagWithParent() {
  112. if (defined(href)) {
  113. return <ExternalLink href={href}>{tag}</ExternalLink>;
  114. }
  115. if (defined(to) && defined(onClick)) {
  116. return (
  117. <Link to={to} onClick={onClick}>
  118. {tag}
  119. </Link>
  120. );
  121. }
  122. if (defined(to)) {
  123. return <Link to={to}>{tag}</Link>;
  124. }
  125. return tag;
  126. }
  127. return (
  128. <TagWrapper {...props} onClick={trackClickEvent}>
  129. {tagWithParent()}
  130. </TagWrapper>
  131. );
  132. }
  133. const TagWrapper = styled('span')`
  134. font-size: ${p => p.theme.fontSizeSmall};
  135. `;
  136. export const Background = styled('div')<{type: keyof Theme['tag']}>`
  137. display: inline-flex;
  138. align-items: center;
  139. height: ${TAG_HEIGHT};
  140. border-radius: ${TAG_HEIGHT};
  141. background-color: ${p => p.theme.tag[p.type].background};
  142. border: solid 1px ${p => p.theme.tag[p.type].border};
  143. padding: 0 ${space(1)};
  144. `;
  145. const IconWrapper = styled('span')`
  146. margin-right: ${space(0.5)};
  147. display: inline-flex;
  148. `;
  149. const Text = styled('span')<{maxWidth: number; type: keyof Theme['tag']}>`
  150. color: ${p =>
  151. ['black', 'white'].includes(p.type)
  152. ? p.theme.tag[p.type].iconColor
  153. : p.theme.textColor};
  154. max-width: ${p => p.maxWidth}px;
  155. overflow: hidden;
  156. white-space: nowrap;
  157. text-overflow: ellipsis;
  158. line-height: ${TAG_HEIGHT};
  159. `;
  160. const DismissButton = styled(Button)`
  161. margin-left: ${space(0.5)};
  162. border: none;
  163. `;
  164. export default Tag;