elements_Text_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { spacerClasses } from '@gravityforms/utils';

const { forwardRef, useEffect, useRef } = React;

/**
 * @module Text
 * @description Wraps html text with some preset style options in a configurable tag container.
 * When editable is true, the text becomes editable on focus, with accessibility and onChange support.
 * Optionally, a hidden input can store the value for form submission.
 *
 * @since 1.1.15
 *
 * @param {object}                     props                  Component props.
 * @param {boolean}                    props.asHtml           Whether or not to accept HTML in the content (ignored when editable).
 * @param {JSX.Element}                props.children         React element children (ignored when editable).
 * @param {string}                     props.color            The text color.
 * @param {string}                     props.content          The text content.
 * @param {object}                     props.customAttributes Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses    Custom classes for the component.
 * @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 container element.
 * @param {string}                     props.weight           The font weight for the heading.
 * @param {boolean}                    props.editable         Whether the text is editable on focus (default false).
 * @param {function}                   props.onChange         Handler for text changes when editable.
 * @param {string}                     props.name             Optional name attribute when editable.
 * @param {string}                     props.id               Optional id attribute when editable.
 * @param {boolean}                    props.useHiddenInput   Whether to include a hidden input for form submission (default false).
 * @param {object|null}                ref                    Ref to the component.
 *
 * @return {JSX.Element} The text component.
 *
 * @example
 * // Controlled usage
 * const [text, setText] = useState("Hello world");
 * <Text content={text} editable onChange={setText} />
 *
 * // Self-managed form usage
 * <form>
 *   <Text content="Hello world" editable useHiddenInput name="text-field" id="text-1" />
 *   <button type="submit">Submit</button>
 * </form>
 */
const Text = forwardRef( ( {
	asHtml = false,
	children = null,
	color = 'port',
	content = '',
	customAttributes = {},
	customClasses = [],
	editable = false,
	id = '',
	name = '',
	onChange = () => {},
	size = 'text-md',
	spacing = '',
	tagName = 'div',
	useHiddenInput = false,
	weight = 'regular',
}, ref ) => {
	const effectiveAsHtml = asHtml && ! editable;
	const internalRef = useRef( null );
	const combinedRef = ref || internalRef;
	const hiddenInputRef = useRef( null ); // Ref for hidden input

	const componentProps = {
		className: classnames( {
			'gform-text': true,
			[ `gform-text--color-${ color }` ]: true,
			[ `gform-typography--size-${ size }` ]: true,
			[ `gform-typography--weight-${ weight }` ]: true,
			...spacerClasses( spacing ),
		}, customClasses ),
		ref: combinedRef,
		...customAttributes,
	};

	if ( editable ) {
		componentProps.contentEditable = true;
		componentProps.tabIndex = 0;
		componentProps.role = 'textbox';
		// Only apply name/id to container if not using hidden input
		if ( ! useHiddenInput ) {
			if ( name ) {
				componentProps.name = name;
			}
			if ( id ) {
				componentProps.id = id;
			}
		}

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

	if ( effectiveAsHtml ) {
		componentProps.dangerouslySetInnerHTML = { __html: content };
	}

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

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

			const handleInput = () => {
				const newText = element.textContent;
				if ( onChange ) {
					onChange( newText );
				}
				if ( useHiddenInput && hiddenInputRef.current ) {
					hiddenInputRef.current.value = newText; // Sync hidden input
				}
			};

			element.addEventListener( 'input', handleInput );
			return () => element.removeEventListener( 'input', handleInput );
		}
	}, [ editable, content, onChange, useHiddenInput, combinedRef ] );

	const Container = tagName;

	if ( effectiveAsHtml ) {
		return <Container { ...componentProps } />;
	}

	if ( editable ) {
		return (
			<>
				<Container { ...componentProps } />
				{ useHiddenInput && (
					<input
						type="hidden"
						ref={ hiddenInputRef }
						name={ name }
						id={ id }
						defaultValue={ content }
					/>
				) }
			</>
		);
	}

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

Text.propTypes = {
	asHtml: PropTypes.bool,
	children: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	color: PropTypes.string,
	content: PropTypes.string,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	editable: PropTypes.bool,
	id: PropTypes.string,
	name: PropTypes.string,
	onChange: PropTypes.func,
	size: PropTypes.string,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	tagName: PropTypes.string,
	useHiddenInput: PropTypes.bool,
	weight: PropTypes.string,
};

Text.displayName = 'Text';

export default Text;