breadcrumbItem.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import {memo, useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon';
  4. import {PanelItem} from 'sentry/components/panels';
  5. import {getDetails} from 'sentry/components/replays/breadcrumbs/utils';
  6. import {Tooltip} from 'sentry/components/tooltip';
  7. import {SVGIconProps} from 'sentry/icons/svgIcon';
  8. import {space} from 'sentry/styles/space';
  9. import type {Crumb} from 'sentry/types/breadcrumbs';
  10. import TimestampButton from 'sentry/views/replays/detail/timestampButton';
  11. type MouseCallback = (crumb: Crumb, e: React.MouseEvent<HTMLElement>) => void;
  12. interface Props {
  13. crumb: Crumb;
  14. isHovered: boolean;
  15. isSelected: boolean;
  16. onClick: null | MouseCallback;
  17. startTimestampMs: number;
  18. allowHover?: boolean;
  19. onMouseEnter?: MouseCallback;
  20. onMouseLeave?: MouseCallback;
  21. }
  22. function BreadcrumbItem({
  23. crumb,
  24. isHovered,
  25. isSelected,
  26. startTimestampMs,
  27. allowHover = true,
  28. onMouseEnter,
  29. onMouseLeave,
  30. onClick,
  31. }: Props) {
  32. const {title, description} = getDetails(crumb);
  33. const handleMouseEnter = useCallback(
  34. (e: React.MouseEvent<HTMLElement>) => onMouseEnter && onMouseEnter(crumb, e),
  35. [onMouseEnter, crumb]
  36. );
  37. const handleMouseLeave = useCallback(
  38. (e: React.MouseEvent<HTMLElement>) => onMouseLeave && onMouseLeave(crumb, e),
  39. [onMouseLeave, crumb]
  40. );
  41. const handleClick = useCallback(
  42. (e: React.MouseEvent<HTMLElement>) => {
  43. onClick?.(crumb, e);
  44. },
  45. [crumb, onClick]
  46. );
  47. return (
  48. <CrumbItem
  49. as={onClick ? 'button' : 'span'}
  50. onMouseEnter={handleMouseEnter}
  51. onMouseLeave={handleMouseLeave}
  52. onClick={handleClick}
  53. isHovered={isHovered}
  54. isSelected={isSelected}
  55. aria-current={isSelected}
  56. allowHover={allowHover}
  57. >
  58. <IconWrapper color={crumb.color}>
  59. <BreadcrumbIcon type={crumb.type} />
  60. </IconWrapper>
  61. <CrumbDetails>
  62. <TitleContainer>
  63. <Title>{title}</Title>
  64. {onClick ? (
  65. <TimestampButton
  66. startTimestampMs={startTimestampMs}
  67. timestampMs={crumb.timestamp || ''}
  68. />
  69. ) : null}
  70. </TitleContainer>
  71. <Tooltip title={description} showOnlyOnOverflow>
  72. <Description>{description}</Description>
  73. </Tooltip>
  74. </CrumbDetails>
  75. </CrumbItem>
  76. );
  77. }
  78. const CrumbDetails = styled('div')`
  79. display: flex;
  80. flex-direction: column;
  81. overflow: hidden;
  82. `;
  83. const TitleContainer = styled('div')`
  84. display: flex;
  85. justify-content: space-between;
  86. gap: ${space(1)};
  87. `;
  88. const Title = styled('span')`
  89. ${p => p.theme.overflowEllipsis};
  90. text-transform: capitalize;
  91. font-weight: 600;
  92. color: ${p => p.theme.gray400};
  93. line-height: ${p => p.theme.text.lineHeightBody};
  94. `;
  95. const Description = styled('span')`
  96. ${p => p.theme.overflowEllipsis};
  97. font-size: 0.7rem;
  98. font-variant-numeric: tabular-nums;
  99. line-height: ${p => p.theme.text.lineHeightBody};
  100. color: ${p => p.theme.subText};
  101. `;
  102. type CrumbItemProps = {
  103. isHovered: boolean;
  104. isSelected: boolean;
  105. allowHover?: boolean;
  106. };
  107. const CrumbItem = styled(PanelItem)<CrumbItemProps>`
  108. display: grid;
  109. grid-template-columns: max-content auto;
  110. align-items: flex-start;
  111. gap: ${space(1)};
  112. width: 100%;
  113. font-size: ${p => p.theme.fontSizeMedium};
  114. background: transparent;
  115. padding: ${space(1)};
  116. text-align: left;
  117. border: none;
  118. position: relative;
  119. ${p => p.isSelected && `background-color: ${p.theme.purple100};`}
  120. ${p => p.isHovered && `background-color: ${p.theme.surface200};`}
  121. border-radius: ${p => p.theme.borderRadius};
  122. ${p =>
  123. p.allowHover &&
  124. ` &:hover {
  125. background-color: ${p.theme.surface200};
  126. }`}
  127. /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */
  128. &::after {
  129. content: '';
  130. position: absolute;
  131. left: 19.5px;
  132. width: 1px;
  133. background: ${p => p.theme.gray200};
  134. height: 100%;
  135. }
  136. &:first-of-type::after {
  137. top: ${space(1)};
  138. bottom: 0;
  139. }
  140. &:last-of-type::after {
  141. top: 0;
  142. height: ${space(1)};
  143. }
  144. &:only-of-type::after {
  145. height: 0;
  146. }
  147. `;
  148. /**
  149. * Taken `from events/interfaces/.../breadcrumbs/types`
  150. */
  151. const IconWrapper = styled('div')<Required<Pick<SVGIconProps, 'color'>>>`
  152. display: flex;
  153. align-items: center;
  154. justify-content: center;
  155. width: 24px;
  156. height: 24px;
  157. border-radius: 50%;
  158. color: ${p => p.theme.white};
  159. background: ${p => p.theme[p.color] ?? p.color};
  160. box-shadow: ${p => p.theme.dropShadowLight};
  161. position: relative;
  162. z-index: ${p => p.theme.zIndex.initial};
  163. `;
  164. const MemoizedBreadcrumbItem = memo(BreadcrumbItem);
  165. export default MemoizedBreadcrumbItem;