*/
private array $style = array();
/**
* Constructor.
*
* @param Form $form Form to render.
* @param Submission_Result|null $result Result of a submission on this request.
*/
public function __construct(
private readonly Form $form,
private readonly ?Submission_Result $result = null,
) {
self::$instances[ $form->id ] = ( self::$instances[ $form->id ] ?? 0 ) + 1;
$suffix = self::$instances[ $form->id ] > 1 ? '-' . self::$instances[ $form->id ] : '';
$this->id_prefix = 'tflexforms-' . $form->id . $suffix;
}
/**
* Renders a published form by ID. Returns '' when it does not exist,
* plus a hint for users who can manage forms.
*
* @param int $form_id Form ID.
*/
public static function render_by_id( int $form_id ): string {
$form = Form::find( $form_id );
if ( null === $form ) {
if ( current_user_can( 'edit_tflexforms_forms' ) ) {
return '' . esc_html__( 'Flexsolutions Form Builder: this form does not exist or is not published.', 'flexsolutions-form-builder' ) . '
';
}
return '';
}
wp_enqueue_style( 'flexsolutions-form-builder' );
if ( ! empty( $form->settings['logged_in_only'] ) && ! is_user_logged_in() ) {
return sprintf(
'',
esc_html( $form->message( 'login_required' ) )
);
}
wp_enqueue_script( 'flexsolutions-form-builder' );
return ( new self( $form, Submission_Handler::result_for( $form ) ) )->render();
}
/**
* Help text right after a field: "" or "" (when the
* field has a label), then the builder's .
*/
private const HELP_AFTER_FIELD = '/^(\s*(?:<\/label>|<\/fieldset>)?\s*)/';
/**
* Builds the form markup.
*/
public function render(): string {
$segments = $this->form->segments();
$allowed = Form::allowed_template_html();
// Only the first instance on the page shows the submission result.
$show_result = null !== $this->result && 1 === self::$instances[ $this->form->id ];
$sent = $show_result && $this->result->is_success();
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$action = remove_query_arg( Submission_Handler::QUERY_SENT, $request_uri ) . '#tflexforms-' . $this->form->id;
// Style tab: preset, label and button classes and the accent colour.
$style = Style::get( $this->form->id );
$this->style = $style;
$inline = Style::inline_style( $style );
$html = sprintf( ''
: ' ';
continue;
}
if ( $segment instanceof Field && 'response' === $segment->type ) {
// [response] places the message box; only the first one counts.
$html .= $response;
$response = '';
continue;
}
if ( $segment instanceof Field ) {
// Help text the builder puts right after a field describes it.
$next = $segments[ $index + 1 ] ?? null;
$help_id = is_string( $next ) && 1 === preg_match( self::HELP_AFTER_FIELD, $next ) ? $this->control_id( $segment ) . '-help' : '';
$html .= $this->field( $segment, $sent, $help_id );
continue;
}
$segment = wp_kses( $segment, $allowed );
if ( '' !== $help_id ) {
$segment = (string) preg_replace( self::HELP_AFTER_FIELD, '$1', $segment, 1 );
$help_id = '';
}
$html .= $segment;
}
$html .= $captcha . $response;
$html .= '
';
/**
* Filters the final form HTML.
*
* @param string $html Form HTML.
* @param Form $form Form.
*/
return (string) apply_filters( 'tflexforms_form_html', $html, $this->form );
}
/**
* Validation rules and messages for instant checks in the browser. They
* mirror Validator exactly; the server still re-checks everything. Quiz
* answers are never included.
*
* @return array{fields:array>,messages:array}
*/
private function schema(): array {
$fields = array();
foreach ( $this->form->input_fields() as $name => $field ) {
if ( 'hidden' === $field->type ) {
continue;
}
$default_max = 'textarea' === $field->type ? Validator::DEFAULT_MAX_TEXT : Validator::DEFAULT_MAX_LINE;
$rule = array(
'type' => $field->type,
'required' => $field->required,
'multiple' => $field->is_multiple(),
);
if ( $field->is_composite() ) {
$rule['parts'] = array_map( static fn( array $part ): bool => $part['required'], $field->parts() );
$rule['maxlength'] = Field::PART_MAX;
} elseif ( in_array( $field->type, array( 'text', 'email', 'url', 'tel', 'textarea', 'quiz', 'name' ), true ) ) {
$rule['maxlength'] = min( (int) ( $field->option( 'maxlength' ) ?? $default_max ), $default_max );
$rule['minlength'] = (int) ( $field->option( 'minlength' ) ?? 0 );
}
if ( in_array( $field->type, array( 'number', 'date', 'range' ), true ) ) {
$rule['min'] = $field->option( 'min' ) ?? ( 'range' === $field->type ? '0' : null );
$rule['max'] = $field->option( 'max' ) ?? ( 'range' === $field->type ? '100' : null );
$rule['step'] = $field->option( 'step' ) ?? '1';
}
if ( 'file' === $field->type ) {
$rule['limit'] = Uploads::limit( $field );
$rule['types'] = Uploads::types( $field );
}
if ( $field->has_choices() ) {
$rule['choices'] = $field->choices();
$rule['exclusive'] = $field->has_flag( 'exclusive' );
}
$fields[ $name ] = $rule;
}
$messages = array();
foreach ( array( 'validation_error', 'required', 'invalid_email', 'invalid_url', 'invalid_tel', 'invalid_number', 'number_too_small', 'number_too_large', 'invalid_date', 'date_too_early', 'date_too_late', 'too_long', 'too_short', 'invalid_choice', 'acceptance', 'file_too_large', 'file_type' ) as $key ) {
$messages[ $key ] = $this->form->message( $key );
}
return array(
'fields' => $fields,
'messages' => $messages,
);
}
/**
* Response region, announced by screen readers.
*
* @param Submission_Result|null $result Result.
*/
private function response( ?Submission_Result $result ): string {
if ( null === $result ) {
return '
';
}
$class = $result->is_success() ? 'tflexforms-response is-success' : 'tflexforms-response is-error';
$role = $result->is_success() ? 'status' : 'alert';
return sprintf( '%s
', esc_attr( $class ), esc_attr( $role ), esc_html( $result->message ) );
}
/**
* Renders one field with its error message.
*
* @param Field $field Field.
* @param bool $sent Whether the form was just sent (values are cleared).
* @param string $help_id Id of the help text that describes the field, if any.
*/
private function field( Field $field, bool $sent, string $help_id = '' ): string {
if ( 'submit' === $field->type ) {
$label = '' !== $field->label() ? $field->label() : __( 'Send', 'flexsolutions-form-builder' );
return sprintf( '%s ', esc_attr( $this->classes( $field, implode( ' ', array_merge( array( 'tflexforms-submit' ), array() !== $this->style ? Style::button_classes( $this->style ) : array() ) ) ) ), esc_html( $label ) );
}
$id = $this->control_id( $field );
$error = $this->result?->errors[ $field->name ] ?? null;
$value = $sent ? null : ( $this->result?->values[ $field->name ] ?? null );
$value ??= self::initial_value( $field );
if ( 'hidden' === $field->type ) {
return sprintf( ' ', esc_attr( $field->name ), esc_attr( is_string( $value ) ? $value : '' ) );
}
$counter_id = $field->has_flag( 'counter' ) && in_array( $field->type, array( 'text', 'email', 'url', 'tel', 'textarea', 'name' ), true ) && ! $field->is_composite() ? $id . '-count' : '';
$described = array_filter( array( null !== $error ? $id . '-error' : '', $help_id, $counter_id ) );
$common = array(
'id' => $id,
'name' => $field->name,
'class' => $this->classes( $field, 'tflexforms-control' ),
'aria-invalid' => null !== $error ? 'true' : null,
'aria-describedby' => array() !== $described ? implode( ' ', $described ) : null,
);
$custom = Extensions::field_type( $field->type );
$parts = $sent ? array() : (array) ( $this->result?->values[ Validator::parts_key( $field->name ) ] ?? array() );
$control = null !== $custom ? (string) call_user_func( $custom['render'], $field, $common, $value ) : match ( $field->is_composite() ? 'parts' : $field->type ) {
'parts' => $this->parts( $field, $common, $parts ),
'textarea' => $this->textarea( $field, $common, is_string( $value ) ? $value : '' ),
'quiz' => $this->quiz( $field, $common ),
'file' => $this->file( $field, $common ),
'range' => $this->range( $field, $common, is_string( $value ) ? $value : '' ),
'select' => $this->select( $field, $common, $value ),
'checkbox', 'radio' => $this->choices( $field, $common, $value, $sent ? '' : (string) ( $this->result?->values[ Validator::free_text_key( $field->name ) ] ?? '' ) ),
'acceptance' => $this->acceptance( $field, $common, '1' === $value ),
default => $this->input( $field, $common, is_string( $value ) ? $value : '' ),
};
$html = sprintf( '%s', esc_attr( $field->type ), $control );
if ( '' !== $counter_id ) {
$max = $field->option( 'maxlength' ) ?? (string) ( 'textarea' === $field->type ? Validator::DEFAULT_MAX_TEXT : Validator::DEFAULT_MAX_LINE );
/* translators: 1: characters used, 2: maximum characters. */
$format = __( '%1$d of %2$d characters', 'flexsolutions-form-builder' );
$html .= sprintf(
'%5$s ',
esc_attr( $counter_id ),
esc_attr( $id ),
esc_attr( $max ),
esc_attr( $format ),
esc_html( sprintf( $format, mb_strlen( is_string( $value ) ? $value : '' ), (int) $max ) )
);
}
if ( null !== $error ) {
$html .= sprintf( '%s ', esc_attr( $id . '-error' ), esc_html( $error ) );
}
return $html . ' ';
}
/**
* Initial value for text-like and hidden fields: a dynamic default
* (default:get, default:post_title, default:user_email, ...) or the static
* quoted value. Output is always escaped, so reflected URL values are safe.
*
* @param Field $field Field.
*/
private static function initial_value( Field $field ): ?string {
if ( $field->has_choices() || $field->is_composite() || in_array( $field->type, array( 'acceptance', 'submit' ), true ) ) {
return null;
}
$source = $field->option( 'default' );
$value = null;
if ( 'get' === $source ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Prefilling a field from the URL; nothing is changed. Sanitized on the next line by field type.
$raw = isset( $_GET[ $field->name ] ) && is_string( $_GET[ $field->name ] ) ? wp_unslash( $_GET[ $field->name ] ) : '';
$value = 'textarea' === $field->type ? sanitize_textarea_field( $raw ) : sanitize_text_field( $raw );
} elseif ( null !== $source && str_starts_with( $source, 'post_' ) ) {
$post = get_post();
$value = match ( true ) {
! $post instanceof \WP_Post => '',
'post_id' === $source => (string) $post->ID,
'post_title' === $source => html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
default => (string) get_permalink( $post ),
};
} elseif ( null !== $source && str_starts_with( $source, 'user_' ) ) {
$user = wp_get_current_user();
$value = match ( true ) {
! $user->exists() => '',
'user_login' === $source => $user->user_login,
'user_email' === $source => $user->user_email,
default => $user->display_name,
};
}
return null !== $value && '' !== $value ? $value : $field->default_value();
}
/**
* Text-like .
*
* @param Field $field Field.
* @param array $common Shared attributes.
* @param string $value Current value.
*/
private function input( Field $field, array $common, string $value ): string {
$attrs = $common + array(
'type' => 'name' === $field->type ? 'text' : $field->type,
'value' => $value,
'required' => $this->html_required( $field ),
'aria-required' => $field->required ? 'true' : null,
'placeholder' => '' !== $field->placeholder() ? $field->placeholder() : null,
'autocomplete' => $field->option( 'autocomplete' ) ?? ( 'name' === $field->type ? 'name' : null ),
'maxlength' => in_array( $field->type, array( 'number', 'date' ), true ) ? null : ( $field->option( 'maxlength' ) ?? (string) Validator::DEFAULT_MAX_LINE ),
'minlength' => in_array( $field->type, array( 'number', 'date' ), true ) ? null : $field->option( 'minlength' ),
'min' => in_array( $field->type, array( 'number', 'date' ), true ) ? $field->option( 'min' ) : null,
'max' => in_array( $field->type, array( 'number', 'date' ), true ) ? $field->option( 'max' ) : null,
'step' => 'number' === $field->type ? $field->option( 'step' ) : null,
'inputmode' => 'tel' === $field->type ? 'tel' : null,
'size' => $field->option( 'size' ),
'readonly' => $field->has_flag( 'readonly' ),
'tabindex' => $field->option( 'tabindex' ),
);
return ' ';
}
/**
* The inputs of a split name or an address, each with its own label and
* autocomplete token, posted as name[part].
*
* @param Field $field Composite field.
* @param array $common Shared attributes.
* @param array $values Current text of each part.
*/
private function parts( Field $field, array $common, array $values ): string {
$html = sprintf(
'',
esc_attr( $field->type ),
esc_attr( (string) $common['id'] ),
null !== $common['aria-describedby'] ? ' aria-describedby="' . esc_attr( (string) $common['aria-describedby'] ) . '"' : ''
);
foreach ( $field->parts() as $key => $part ) {
$required = $field->required && $part['required'];
$attrs = array(
'type' => 'text',
'id' => $common['id'] . '-' . $key,
'name' => $field->name . '[' . $key . ']',
'value' => is_string( $values[ $key ] ?? null ) ? $values[ $key ] : '',
'class' => $common['class'],
'required' => $required && $this->html_required( $field ),
'aria-required' => $required ? 'true' : null,
'aria-invalid' => $common['aria-invalid'],
'aria-describedby' => $common['aria-describedby'],
'autocomplete' => $part['autocomplete'],
'maxlength' => (string) Field::PART_MAX,
'readonly' => $field->has_flag( 'readonly' ),
'tabindex' => $field->option( 'tabindex' ),
);
$html .= sprintf(
'%3$s%4$s ',
esc_attr( $key ),
esc_attr( (string) $attrs['id'] ),
esc_html( $part['label'] ),
$required ? ' * ' : '',
self::attributes( $attrs )
);
}
return $html . ' ';
}
/**
*