clippedBox.tsx 4.5 KB

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