elements_Heading_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { ConditionalWrapper } from '@gravityforms/react-utils';
import { spacerClasses, clipboard } from '@gravityforms/utils';
import Button from '../Button';

const { forwardRef, useEffect, useRef } = React;

/**
 * @module Heading
 * @description The heading component with optional editability and a copy-to-clipboard button.
 * When editable is true, the heading becomes editable on focus, with accessibility and onChange support.
 * Optionally, a hidden input can store the value for form submission, and a button can copy content to the clipboard inline.
 *
 * @since 1.1.15
 *
 * @param {object}                     props                      Component props.
 * @param {JSX.Element}                props.children             React element children (ignored when editable).
 * @param {string}                     props.content              The text content.
 * @param {object}                     props.copyButtonAttributes Custom attributes for the copy button.
 * @param {string|Array|object}        props.copyButtonClasses    Custom classes for the copy button.
 * @param {object}                     props.customAttributes     Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses        Custom classes for the component.
 * @param {boolean}                    props.editable             Whether the heading is editable on focus (default false).
 * @param {string}                     props.id                   Optional id attribute when editable.
 * @param {string}                     props.name                 Optional name attribute when editable.
 * @param {Function}                   props.onBlur               Handler for blur event when editable.
 * @param {Function}                   props.onChange             Handler for text changes when editable.
 * @param {Function}                   props.onCopy               Handler for copy event when editable.
 * @param {Function}                   props.onFocus              Handler for focus event when editable.
 * @param {string}                     props.placeholder          Placeholder text when editable and empty (default empty string).
 * @param {boolean}                    props.showCopyButton       Whether to show a button to copy the content to the clipboard (default false).
 * @param {string}                     props.size                 The font size for the heading.
 * @param {string|number|Array|object} props.spacing              The spacing for the component.
 * @param {string}                     props.tagName              The tag used for the heading, from `h1` to `h6`.
 * @param {string}                     props.type                 The type of the heading, one of `regular` or `boxed`.
 * @param {boolean}                    props.useHiddenInput       Whether to include a hidden input for form submission (default false).
 * @param {string}                     props.weight               The font weight for the heading.
 * @param {object|null}                ref                        Ref to the component.
 *
 * @return {JSX.Element} The heading component.
 *
 * @example
 * // With copy button inline, non-editable
 * <Heading tagName="h2" content="Copy Me" showCopyButton />
 *
 * // Controlled usage with copy button inline
 * const [heading, setHeading] = useState("Editable Heading");
 * <Heading tagName="h2" content={heading} editable onChange={setHeading} showCopyButton />
 *
 * // Self-managed form usage with copy button inline
 * <form>
 *   <Heading tagName="h2" content="Editable Heading" editable useHiddenInput name="heading" id="heading-1" showCopyButton />
 *   <button type="submit">Submit</button>
 * </form>
 *
 */
const Heading = forwardRef( ( {
	children = null,
	content = '',
	copyButtonAttributes = {},
	copyButtonClasses = [],
	customAttributes = {},
	customClasses = [],
	editable = false,
	editButtonAttributes = {},
	editButtonClasses = [],
	id = '',
	name = '',
	onBlur = () => {},
	onChange = () => {},
	onCopy = () => {},
	onFocus = () => {},
	placeholder = '',
	showCopyButton = false,
	size = 'display-3xl',
	spacing = '',
	tagName = 'h1',
	type = 'regular',
	useHiddenInput = false,
	weight = 'semibold',
}, ref ) => {
	const internalRef = useRef( null );
	const combinedRef = ref || internalRef;
	const hiddenInputRef = useRef( null );

	const headingAttributes = {
		className: classnames( {
			'gform-heading': true,
			'gform-heading--editable': editable,
			'gform-heading--has-copy': showCopyButton,
			[ `gform-typography--size-${ size }` ]: true,
			[ `gform-typography--weight-${ weight }` ]: true,
			[ `gform-heading--${ type }` ]: true,
			...spacerClasses( spacing ),
		}, customClasses ),
		ref: combinedRef,
		...customAttributes,
	};

	const handleCopy = () => {
		const textToCopy = editable ? combinedRef.current.textContent : content;
		clipboard( textToCopy );
		onCopy();
	};

	const handleEdit = () => {
		combinedRef.current.focus();
	};

	const editButtonProps = {
		customClasses: classnames( {
			'gform-heading__edit-button': true,
			[ `gform-typography--size-${ size }` ]: true,
		}, editButtonClasses ),
		icon: 'edit',
		iconPrefix: 'gravity-component-icon',
		onClick: handleEdit,
		type: 'unstyled',
		...editButtonAttributes,
	};

	const copyButtonProps = {
		customClasses: classnames( {
			'gform-heading__copy-button': true,
			[ `gform-typography--size-${ size }` ]: true,
		}, copyButtonClasses ),
		icon: 'copy-alt',
		iconPrefix: 'gravity-component-icon',
		onClick: handleCopy,
		type: 'unstyled',
		...copyButtonAttributes,
	};

	const editableWithCopyWrapperProps = {
		className: classnames( {
			'gform-heading__wrapper': true,
		}, [] ),
	};

	if ( editable ) {
		headingAttributes.contentEditable = true;
		headingAttributes.placeholder = placeholder;
		headingAttributes.tabIndex = 0;
		headingAttributes.role = 'textbox';
		if ( ! useHiddenInput ) {
			if ( name ) {
				headingAttributes.name = name;
			}
			if ( id ) {
				headingAttributes.id = id;
			}
		}

		if ( ! onChange && ! useHiddenInput ) {
			console.warn( 'Heading component: when editable is true and useHiddenInput is false, onChange prop is recommended for controlled behavior.' );
		}
	}

	useEffect( () => {
		if ( editable && combinedRef.current ) {
			const element = combinedRef.current;

			if ( element.textContent !== content ) {
				element.textContent = content;
			}

			const handleInput = () => {
				const newText = element.textContent;
				if ( newText.trim() === '' ) {
					element.textContent = content;
				}
				if ( onChange ) {
					onChange( newText );
				}
				if ( useHiddenInput && hiddenInputRef.current ) {
					hiddenInputRef.current.value = newText;
				}
			};

			const handleBlur = () => {
				if ( element.textContent.trim() === '' ) {
					element.textContent = content;
				}
				onBlur( element.textContent );
			};
			const handleFocus = () => {
				onFocus( element.textContent );
			};

			element.addEventListener( 'input', handleInput );
			element.addEventListener( 'blur', handleBlur );
			element.addEventListener( 'focus', handleFocus );

			return () => {
				element.removeEventListener( 'input', handleInput );
				element.removeEventListener( 'blur', handleBlur );
				element.removeEventListener( 'focus', handleFocus );
			};
		}
	}, [ editable, content, onChange, useHiddenInput, combinedRef, onBlur, onFocus ] );

	const Container = tagName;

	const copyButton = showCopyButton ? (
		<Button { ...copyButtonProps } />
	) : null;

	const editButton = editable ? (
		<Button { ...editButtonProps } />
	) : null;

	if ( editable ) {
		return (
			<>
				<ConditionalWrapper
					condition={ showCopyButton || editable }
					wrapper={ ( ch ) => <div { ...editableWithCopyWrapperProps }>{ ch }</div> }
				>
					<Container { ...headingAttributes } />
					{ editButton }
					{ copyButton }
				</ConditionalWrapper>
				{ useHiddenInput && (
					<input
						type="hidden"
						ref={ hiddenInputRef }
						name={ name }
						id={ id }
						defaultValue={ content }
					/>
				) }
			</>
		);
	}

	return (
		<Container { ...headingAttributes }>
			{ content }
			{ children }
			{ copyButton }
		</Container>
	);
} );

Heading.propTypes = {
	children: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	content: PropTypes.string,
	copyButtonAttributes: PropTypes.object,
	copyButtonClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	editable: PropTypes.bool,
	id: PropTypes.string,
	name: PropTypes.string,
	onBlur: PropTypes.func,
	onChange: PropTypes.func,
	onCopy: PropTypes.func,
	onFocus: PropTypes.func,
	placeholder: PropTypes.string,
	showCopyButton: PropTypes.bool,
	size: PropTypes.string,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	tagName: PropTypes.string,
	type: PropTypes.string,
	useHiddenInput: PropTypes.bool,
	weight: PropTypes.string,
};

Heading.displayName = 'Heading';

export default Heading;