import { Component, createRef } from 'react'; import styles from '@patternfly/react-styles/css/components/ExpandableSection/expandable-section'; import { css } from '@patternfly/react-styles'; import lineClamp from '@patternfly/react-tokens/dist/esm/c_expandable_section_m_truncate__content_LineClamp'; import RhMicronsCaretDownIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-caret-down-icon'; import { PickOptional } from '../../helpers/typeUtils'; import { debounce } from '../../helpers/util'; import { getResizeObserver } from '../../helpers/resizeObserver'; import { GenerateId } from '../../helpers'; import { Button } from '../Button'; export enum ExpandableSectionVariant { default = 'default', truncate = 'truncate' } /** The main expandable section component. */ export interface ExpandableSectionProps extends Omit, 'onToggle'> { /** Content rendered inside the expandable section. */ children?: React.ReactNode; /** Additional classes added to the expandable section. */ className?: string; /** Id of the content of the expandable section. When passing in the isDetached property, this * property's value should match the contentId property of the expandable section toggle sub-component. */ contentId?: string; /** Id of the toggle of the expandable section, which provides an accessible name to the * expandable section content via the aria-labelledby attribute. When the isDetached property * is also passed in, the value of this property must match the toggleId property of the * expandable section toggle sub-component. */ toggleId?: string; /** Display size variant. Set to "lg" for disclosure styling. */ displaySize?: 'default' | 'lg'; /** Flag indicating that the expandable section and expandable toggle are detached from one another. */ isDetached?: boolean; /** Flag to indicate if the content is expanded. */ isExpanded?: boolean; /** Flag to indicate if the content is indented. */ isIndented?: boolean; /** Flag to indicate the width of the component is limited. Set to "true" for disclosure styling. */ isWidthLimited?: boolean; /** Callback function to toggle the expandable section. Detached expandable sections should * use the onToggle property of the expandable section toggle sub-component. */ onToggle?: (event: React.MouseEvent, isExpanded: boolean) => void; /** React node that appears in the attached toggle in place of the toggleText property. * Can also be a function that receives the expanded state and returns a React node. */ toggleContent?: React.ReactNode | ((isExpanded: boolean) => React.ReactNode); /** Text that appears in the attached toggle. */ toggleText?: string; /** Text that appears in the attached toggle when collapsed (will override toggleText if * both are specified; used for uncontrolled expandable with dynamic toggle text). */ toggleTextCollapsed?: string; /** Text that appears in the attached toggle when expanded (will override toggleText if * both are specified; used for uncontrolled expandable with dynamic toggle text). */ toggleTextExpanded?: string; /** Accessible name via human readable string for the expandable section toggle. */ toggleAriaLabel?: string; /** Accessible name via space delimtted list of IDs for the expandable section toggle. */ toggleAriaLabelledBy?: string; /** Icon shown in toggle when variant is not truncated. */ toggleIcon?: React.ReactNode; /** Whether to show a toggle icon when variant is not truncated. If omitted, it is important to ensure the current state of the ExpandableSection is conveyed, most likely by having dynamic toggle text. */ hasToggleIcon?: boolean; /** Truncates the expandable content to the specified number of lines when using the * "truncate" variant. */ truncateMaxLines?: number; /** Determines the variant of the expandable section. When passing in "truncate" as the * variant, the expandable content will be truncated after 3 lines by default. */ variant?: 'default' | 'truncate'; /** Sets the direction of the expandable animation when isDetached is true. If this prop is not passed, * animation will not occur. */ direction?: 'up' | 'down'; /** The HTML element to use for the toggle wrapper. Can be 'div' (default) or any heading level. * When using heading elements, the button will be rendered inside the heading for proper semantics. * This is useful when the toggle text should function as a heading in the document structure. */ toggleWrapper?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; } interface ExpandableSectionState { isExpanded: boolean; hasToggle: boolean; previousWidth: number; } const directionClassMap = { up: styles.modifiers.expandTop, down: styles.modifiers.expandBottom }; const setLineClamp = (lines: number, element: HTMLDivElement) => { if (!element || lines < 1) { return; } element.style.setProperty(lineClamp.name, lines.toString()); }; class ExpandableSection extends Component { static displayName = 'ExpandableSection'; constructor(props: ExpandableSectionProps) { super(props); this.state = { isExpanded: props.isExpanded, hasToggle: true, previousWidth: undefined }; } expandableContentRef = createRef(); observer: any = () => {}; static defaultProps: PickOptional = { className: '', toggleText: '', toggleTextExpanded: '', toggleTextCollapsed: '', toggleAriaLabel: undefined, toggleAriaLabelledBy: undefined, // eslint-disable-next-line @typescript-eslint/no-unused-vars onToggle: (event, isExpanded): void => undefined, isDetached: false, displaySize: 'default', isWidthLimited: false, isIndented: false, variant: 'default' }; private calculateToggleText( toggleText: string, toggleTextExpanded: string, toggleTextCollapsed: string, propOrStateIsExpanded: boolean ) { if (propOrStateIsExpanded && toggleTextExpanded !== '') { return toggleTextExpanded; } if (!propOrStateIsExpanded && toggleTextCollapsed !== '') { return toggleTextCollapsed; } return toggleText; } componentDidMount() { if (this.props.variant === ExpandableSectionVariant.truncate) { const expandableContent = this.expandableContentRef.current; this.setState({ previousWidth: expandableContent.offsetWidth }); this.observer = getResizeObserver(expandableContent, this.handleResize, false); if (this.props.truncateMaxLines) { setLineClamp(this.props.truncateMaxLines, expandableContent); } this.checkToggleVisibility(); } } componentDidUpdate(prevProps: ExpandableSectionProps) { if ( this.props.variant === ExpandableSectionVariant.truncate && (prevProps.truncateMaxLines !== this.props.truncateMaxLines || prevProps.children !== this.props.children) ) { const expandableContent = this.expandableContentRef.current; setLineClamp(this.props.truncateMaxLines, expandableContent); this.checkToggleVisibility(); } } componentWillUnmount() { if (this.props.variant === ExpandableSectionVariant.truncate) { this.observer(); } } checkToggleVisibility = () => { if (this.expandableContentRef?.current) { const maxLines = this.props.truncateMaxLines || parseInt(lineClamp.value); const totalLines = this.expandableContentRef.current.scrollHeight / parseInt(getComputedStyle(this.expandableContentRef.current).lineHeight); this.setState({ hasToggle: totalLines > maxLines }); } }; resize = () => { if (this.expandableContentRef.current) { const { offsetWidth } = this.expandableContentRef.current; if (this.state.previousWidth !== offsetWidth) { this.setState({ previousWidth: offsetWidth }); this.checkToggleVisibility(); } } }; handleResize = debounce(this.resize, 250); render() { const { onToggle: onToggleProp, className, toggleText, toggleTextExpanded, toggleTextCollapsed, toggleContent, toggleAriaLabel, toggleAriaLabelledBy, toggleIcon = , hasToggleIcon = true, children, isExpanded, isDetached, displaySize, isWidthLimited, isIndented, contentId, toggleId, variant, // eslint-disable-next-line @typescript-eslint/no-unused-vars truncateMaxLines, direction, toggleWrapper = 'div', ...props } = this.props; if (isDetached && !toggleId) { /* eslint-disable no-console */ console.warn( 'ExpandableSection: The toggleId value must be passed in and must match the toggleId of the ExpandableSectionToggle.' ); } let onToggle = onToggleProp; let propOrStateIsExpanded = isExpanded; // uncontrolled if (isExpanded === undefined) { propOrStateIsExpanded = this.state.isExpanded; onToggle = (event, isOpen) => { this.setState({ isExpanded: isOpen }, () => onToggleProp(event, this.state.isExpanded)); }; } const computedToggleText = this.calculateToggleText( toggleText, toggleTextExpanded, toggleTextCollapsed, propOrStateIsExpanded ); const computedToggleContent = typeof toggleContent === 'function' ? toggleContent(propOrStateIsExpanded) : toggleContent; const ToggleWrapper = toggleWrapper as any; return ( {(genContentId) => ( {(genToggleId) => { const uniqueContentId = contentId || genContentId; const uniqueToggleId = toggleId || genToggleId; const expandableToggle = !isDetached && ( ); return (
{variant === ExpandableSectionVariant.default && expandableToggle} {variant === ExpandableSectionVariant.truncate && this.state.hasToggle && expandableToggle}
); }}
)}
); } } export { ExpandableSection };