clippedBox.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import {PureComponent} from 'react';
  2. import {findDOMNode} from 'react-dom';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import color from 'color';
  6. import Button from 'sentry/components/button';
  7. import {t} from 'sentry/locale';
  8. import space from 'sentry/styles/space';
  9. type DefaultProps = {
  10. btnText?: string;
  11. clipHeight?: number;
  12. defaultClipped?: boolean;
  13. };
  14. type Props = {
  15. clipHeight: number;
  16. className?: string;
  17. /**
  18. * When available replaces the default clipFade component
  19. */
  20. clipFade?: ({showMoreButton}: {showMoreButton: React.ReactNode}) => React.ReactNode;
  21. /**
  22. * Triggered when user clicks on the show more button
  23. */
  24. onReveal?: () => void;
  25. /**
  26. * Its trigged when the component is mounted and its height available
  27. */
  28. onSetRenderedHeight?: (renderedHeight: number) => void;
  29. renderedHeight?: number;
  30. title?: string;
  31. } & DefaultProps;
  32. type State = {
  33. isClipped: boolean;
  34. isRevealed: boolean;
  35. renderedHeight?: number;
  36. };
  37. class ClippedBox extends PureComponent<Props, State> {
  38. static defaultProps: DefaultProps = {
  39. defaultClipped: false,
  40. clipHeight: 200,
  41. btnText: t('Show More'),
  42. };
  43. state: State = {
  44. isClipped: !!this.props.defaultClipped,
  45. isRevealed: false, // True once user has clicked "Show More" button
  46. renderedHeight: this.props.renderedHeight,
  47. };
  48. componentDidMount() {
  49. // eslint-disable-next-line react/no-find-dom-node
  50. const renderedHeight = (findDOMNode(this) as HTMLElement).offsetHeight;
  51. this.props.onSetRenderedHeight?.(renderedHeight);
  52. this.calcHeight(renderedHeight);
  53. }
  54. componentDidUpdate(_prevProps: Props, prevState: State) {
  55. if (prevState.renderedHeight !== this.props.renderedHeight) {
  56. this.setRenderedHeight();
  57. }
  58. if (prevState.renderedHeight !== this.state.renderedHeight) {
  59. this.calcHeight(this.state.renderedHeight);
  60. }
  61. if (this.state.isRevealed || !this.state.isClipped) {
  62. return;
  63. }
  64. if (!this.props.renderedHeight) {
  65. // eslint-disable-next-line react/no-find-dom-node
  66. const renderedHeight = (findDOMNode(this) as HTMLElement).offsetHeight;
  67. if (renderedHeight < this.props.clipHeight) {
  68. this.reveal();
  69. }
  70. }
  71. }
  72. setRenderedHeight() {
  73. this.setState({
  74. renderedHeight: this.props.renderedHeight,
  75. });
  76. }
  77. calcHeight(renderedHeight?: number) {
  78. if (!renderedHeight) {
  79. return;
  80. }
  81. if (!this.state.isClipped && renderedHeight > this.props.clipHeight) {
  82. /* eslint react/no-did-mount-set-state:0 */
  83. // okay if this causes re-render; cannot determine until
  84. // rendered first anyways
  85. this.setState({
  86. isClipped: true,
  87. });
  88. }
  89. }
  90. reveal = () => {
  91. const {onReveal} = this.props;
  92. this.setState({
  93. isClipped: false,
  94. isRevealed: true,
  95. });
  96. if (onReveal) {
  97. onReveal();
  98. }
  99. };
  100. handleClickReveal = (event: React.MouseEvent) => {
  101. event.stopPropagation();
  102. this.reveal();
  103. };
  104. render() {
  105. const {isClipped, isRevealed} = this.state;
  106. const {title, children, clipHeight, btnText, className, clipFade} = this.props;
  107. const showMoreButton = (
  108. <Button
  109. onClick={this.reveal}
  110. priority="primary"
  111. size="xs"
  112. aria-label={btnText ?? t('Show More')}
  113. >
  114. {btnText}
  115. </Button>
  116. );
  117. return (
  118. <Wrapper
  119. clipHeight={clipHeight}
  120. isClipped={isClipped}
  121. isRevealed={isRevealed}
  122. className={className}
  123. >
  124. {title && <Title>{title}</Title>}
  125. {children}
  126. {isClipped &&
  127. (clipFade?.({showMoreButton}) ?? <ClipFade>{showMoreButton}</ClipFade>)}
  128. </Wrapper>
  129. );
  130. }
  131. }
  132. export default ClippedBox;
  133. const Wrapper = styled('div', {
  134. shouldForwardProp: prop =>
  135. prop !== 'clipHeight' && prop !== 'isClipped' && prop !== 'isRevealed',
  136. })<State & {clipHeight: number}>`
  137. position: relative;
  138. border-top: 1px solid ${p => p.theme.backgroundSecondary};
  139. margin-left: -${space(3)};
  140. margin-right: -${space(3)};
  141. padding: ${space(2)} ${space(3)} 0;
  142. :first-of-type {
  143. margin-top: -${space(2)};
  144. border: 0;
  145. }
  146. /* For "Show More" animation */
  147. ${p =>
  148. p.isRevealed &&
  149. css`
  150. transition: all 5s ease-in-out;
  151. max-height: 50000px;
  152. `};
  153. ${p =>
  154. p.isClipped &&
  155. css`
  156. max-height: ${p.clipHeight}px;
  157. overflow: hidden;
  158. `};
  159. `;
  160. const Title = styled('h5')`
  161. margin-bottom: ${space(2)};
  162. `;
  163. const ClipFade = styled('div')`
  164. position: absolute;
  165. left: 0;
  166. right: 0;
  167. bottom: 0;
  168. padding: 40px 0 0;
  169. background-image: linear-gradient(
  170. 180deg,
  171. ${p => color(p.theme.background).alpha(0.15).string()},
  172. ${p => p.theme.background}
  173. );
  174. text-align: center;
  175. border-bottom: ${space(1.5)} solid ${p => p.theme.background};
  176. /* Let pointer-events pass through ClipFade to visible elements underneath it */
  177. pointer-events: none;
  178. /* Ensure pointer-events trigger event listeners on "Expand" button */
  179. > * {
  180. pointer-events: auto;
  181. }
  182. `;