clippedBox.tsx 5.1 KB

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