Files
2024-02-15 17:01:50 +00:00

324 lines
8.7 KiB
TypeScript

import {useCallback, useRef, useState} from 'react';
import styled from '@emotion/styled';
import color from 'color';
import type {ButtonProps} from 'sentry/components/button';
import {Button} from 'sentry/components/button';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
// Content may have margins which can't be measured by our refs, but will affect
// the total content height. We add this to the max-height to ensure the animation
// doesn't cut off early.
const HEIGHT_ADJUSTMENT_FOR_CONTENT_MARGIN = 20;
function isClipped(args: {clipFlex: number; clipHeight: number; height: number}) {
return args.height > args.clipHeight + args.clipFlex;
}
function supportsResizeObserver(
observerOrUndefined: unknown
): observerOrUndefined is ResizeObserver {
return typeof observerOrUndefined !== 'undefined';
}
/**
* The Wrapper component contains padding by default, which may be modified by consumers.
* Without adding this padding to the max-height of the child content, the reveal
* animation will be cut short.
*/
function calculateAddedHeight({
wrapperRef,
}: {
wrapperRef: React.MutableRefObject<HTMLElement | null>;
}): number {
if (wrapperRef.current === null) {
return 0;
}
try {
const {paddingTop, paddingBottom} = getComputedStyle(wrapperRef.current);
const addedHeight =
parseInt(paddingTop, 10) +
parseInt(paddingBottom, 10) +
HEIGHT_ADJUSTMENT_FOR_CONTENT_MARGIN;
return isNaN(addedHeight) ? 0 : addedHeight;
} catch {
return 0;
}
}
function clearMaxHeight(element: HTMLElement | null) {
if (element) {
element.style.maxHeight = 'none';
}
}
function onTransitionEnd(e: TransitionEvent) {
// This can fire for children transitions, so we need to make sure it's the
// reveal animation that has ended.
if (e.target === e.currentTarget && e.propertyName === 'max-height') {
const element = e.currentTarget as HTMLElement;
clearMaxHeight(element);
element.removeEventListener('transitionend', onTransitionEnd);
}
}
function revealAndDisconnectObserver({
contentRef,
observerRef,
revealRef,
wrapperRef,
clipHeight,
}: {
clipHeight: number;
contentRef: React.MutableRefObject<HTMLElement | null>;
observerRef: React.MutableRefObject<ResizeObserver | null>;
revealRef: React.MutableRefObject<boolean>;
wrapperRef: React.MutableRefObject<HTMLElement | null>;
}) {
if (!wrapperRef.current) {
return;
}
const revealedWrapperHeight =
(contentRef.current?.clientHeight || 9999) + calculateAddedHeight({wrapperRef});
// Only animate if the revealed height is greater than the clip height
if (revealedWrapperHeight > clipHeight) {
wrapperRef.current.addEventListener('transitionend', onTransitionEnd);
wrapperRef.current.style.maxHeight = `${revealedWrapperHeight}px`;
} else {
clearMaxHeight(wrapperRef.current);
}
revealRef.current = true;
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
}
const DEFAULT_BUTTON_TEXT = t('Show More');
interface ClippedBoxProps {
btnText?: string;
/**
* Used to customize the button
*/
buttonProps?: Partial<ButtonProps>;
children?: React.ReactNode;
className?: string;
/**
* When available replaces the default clipFade component
*/
clipFade?: ({showMoreButton}: {showMoreButton: React.ReactNode}) => React.ReactNode;
clipFlex?: number;
clipHeight?: number;
defaultClipped?: boolean;
/**
* Triggered when user clicks on the show more button
*/
onReveal?: () => void;
/**
* Its trigged when the component is mounted and its height available
*/
onSetRenderedHeight?: (renderedHeight: number) => void;
renderedHeight?: number;
title?: string;
}
function ClippedBox(props: ClippedBoxProps) {
const revealRef = useRef(false);
const mountedRef = useRef(false);
const observerRef = useRef<ResizeObserver | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [clipped, setClipped] = useState(!!props.defaultClipped);
const onReveal = props.onReveal;
const onSetRenderHeight = props.onSetRenderedHeight;
const clipHeight = props.clipHeight || 200;
const clipFlex = props.clipFlex || 28;
const handleReveal = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!wrapperRef.current) {
throw new Error('Cannot reveal clipped box without a wrapper ref');
}
event.stopPropagation();
revealAndDisconnectObserver({
contentRef,
wrapperRef,
revealRef,
observerRef,
clipHeight,
});
if (typeof onReveal === 'function') {
onReveal();
}
setClipped(false);
},
[clipHeight, onReveal]
);
const onWrapperRef = useCallback(
(node: HTMLDivElement | null) => {
wrapperRef.current = node;
if (!wrapperRef.current) {
return;
}
// Initialize the height to the clip height + clip flex
wrapperRef.current.style.maxHeight = `${clipHeight}px`;
},
[clipHeight]
);
const onContentRef = useCallback(
(node: HTMLDivElement | null) => {
// If the component is revealed, we have nothing to do here
if (revealRef.current) {
return;
}
contentRef.current = node;
// Disconnect the current observer if it exists
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
// If we have no node, we can't observe it
if (!contentRef.current) {
return;
}
const onResize = (entries: ResizeObserverEntry[]): void => {
const entry = entries[0];
if (!entry) {
return;
}
const contentBox = entry.contentBoxSize?.[0];
const borderBox = entry.borderBoxSize?.[0];
const height = contentBox?.blockSize ?? borderBox?.blockSize ?? 0;
if (height === 0) {
return;
}
if (!mountedRef.current && typeof onSetRenderHeight === 'function') {
onSetRenderHeight(height);
mountedRef.current = true;
}
const _clipped = isClipped({
clipFlex,
clipHeight,
height,
});
if (!_clipped && contentRef.current) {
revealAndDisconnectObserver({
contentRef,
wrapperRef,
revealRef,
observerRef,
clipHeight,
});
}
setClipped(_clipped);
};
if (supportsResizeObserver(window.ResizeObserver)) {
observerRef.current = new ResizeObserver(onResize);
observerRef.current.observe(contentRef.current);
return;
}
// If resize observer is not supported, query for rect and call onResize
// with an entry that mimics the ResizeObserverEntry.
const rect: DOMRectReadOnly = contentRef.current.getBoundingClientRect();
const entry: ResizeObserverEntry = {
target: contentRef.current,
contentRect: rect,
contentBoxSize: [{blockSize: rect.height, inlineSize: rect.width}],
borderBoxSize: [{blockSize: rect.height, inlineSize: rect.width}],
devicePixelContentBoxSize: [{blockSize: rect.height, inlineSize: rect.width}],
};
onResize([entry]);
},
[clipFlex, clipHeight, onSetRenderHeight]
);
const showMoreButton = (
<Button
size="xs"
priority="primary"
onClick={handleReveal}
aria-label={props.btnText ?? DEFAULT_BUTTON_TEXT}
{...props.buttonProps}
>
{props.btnText ?? DEFAULT_BUTTON_TEXT}
</Button>
);
return (
<Wrapper ref={onWrapperRef} className={props.className}>
<div ref={onContentRef}>
{props.title ? <Title>{props.title}</Title> : null}
{props.children}
{clipped
? props.clipFade?.({showMoreButton}) ?? <ClipFade>{showMoreButton}</ClipFade>
: null}
</div>
</Wrapper>
);
}
export default ClippedBox;
const Wrapper = styled('div')`
position: relative;
padding: ${space(1.5)} 0;
overflow: hidden;
will-change: max-height;
transition: max-height 500ms ease-in-out;
`;
const Title = styled('h5')`
margin-bottom: ${space(1)};
`;
export const ClipFade = styled('div')`
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 40px 0 0;
background-image: linear-gradient(
180deg,
${p => color(p.theme.background).alpha(0.15).string()},
${p => p.theme.background}
);
text-align: center;
border-bottom: ${space(1.5)} solid ${p => p.theme.background};
/* Let pointer-events pass through ClipFade to visible elements underneath it */
pointer-events: none;
/* Ensure pointer-events trigger event listeners on "Expand" button */
> * {
pointer-events: auto;
}
`;