breadcrumbItem.tsx 4.3 KB

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