breadcrumbItem.tsx 4.6 KB

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