*/ private static array $instances = array(); /** * Prefix for element IDs in this rendered instance. * * @var string */ private string $id_prefix; /** * The form's Style tab settings, read when rendering starts. * * @var array */ 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( '

%s

', 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( '
', esc_attr( 1 === self::$instances[ $this->form->id ] ? 'tflexforms-' . $this->form->id : $this->id_prefix ) ); $html .= sprintf( '
', esc_url( $action ), $this->form->id, esc_attr( 1 === self::$instances[ $this->form->id ] ? $this->form->title : sprintf( '%s (%d)', $this->form->title, self::$instances[ $this->form->id ] ) ), esc_url( rest_url( Rest_Controller::ROUTE_NAMESPACE . '/forms/' . $this->form->id . '/submissions' ) ), esc_attr( $this->form->message( 'mail_failed' ) ), // Logged-in users need the REST nonce so the API sees their session. ( is_user_logged_in() ? ' data-tflexforms-wpnonce="' . esc_attr( wp_create_nonce( 'wp_rest' ) ) . '"' : '' ) . ' data-tflexforms-schema="' . esc_attr( (string) wp_json_encode( $this->schema() ) ) . '"' // Forms with file fields must send multipart data. . ( array() !== array_filter( $this->form->fields(), static fn( Field $f ): bool => 'file' === $f->type ) ? ' enctype="multipart/form-data"' : '' ) . ( '' !== $inline ? ' style="' . esc_attr( $inline ) . '"' : '' ), esc_attr( implode( ' ', array_merge( array( 'tflexforms-form' ), Style::classes( $this->form->id, $style ) ) ) ) ); $hidden = Spam_Guard::hidden_fields( $this->form->id ) + array( Submission_Keys::FIELD => Submission_Keys::new_key() ); foreach ( $hidden as $name => $value ) { $html .= sprintf( '', esc_attr( $name ), esc_attr( $value ) ); } // Honeypot: hidden visually and from assistive technology. $html .= sprintf( '', esc_attr( $this->id_prefix . '-hp' ), esc_html__( 'Leave this field empty', 'flexsolutions-form-builder' ), esc_attr( Spam_Guard::FIELD_HONEYPOT ) ); // The CAPTCHA widget goes just before the first submit button (or at the end). $captcha = Captcha::widget( $this->form ); $response = $this->response( $show_result ? $this->result : null ); $segments = array_values( $segments ); $help_id = ''; foreach ( $segments as $index => $segment ) { if ( '' !== $captcha && $segment instanceof Field && 'submit' === $segment->type ) { $html .= $captcha; $captcha = ''; } if ( $segment instanceof Condition ) { // [if] groups: shown by default (so the form works without // JavaScript); tflexforms.js hides inactive groups. The server // ignores fields of inactive groups either way. $html .= $segment->is_close() ? '
' : '
'; 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( '', 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( '', esc_attr( $key ), esc_attr( (string) $attrs['id'] ), esc_html( $part['label'] ), $required ? ' ' : '', self::attributes( $attrs ) ); } return $html . ''; } /** * '; } /** * '; } /** * Checkbox group or radio group in a fieldset-like wrapper. * * @param Field $field Field. * @param array $common Shared attributes. * @param mixed $value Current value(s). * @param string $free Current "Other" text. */ private function choices( Field $field, array $common, mixed $value, string $free = '' ): string { $is_checkbox = 'checkbox' === $field->type; $selected = is_array( $value ) ? $value : ( is_string( $value ) ? array( $value ) : array() ); $free_choice = $field->free_text_choice(); $required_id = $is_checkbox && $field->required ? $common['id'] . '-required' : ''; $described = trim( (string) ( $common['aria-describedby'] ?? '' ) . ' ' . $required_id ); $described = '' !== $described ? $described : null; $html = sprintf( '%s', $is_checkbox ? 'group' : 'radiogroup', esc_attr( (string) $common['id'] ), null !== $described ? ' aria-describedby="' . esc_attr( $described ) . '"' : '', // aria-required is not allowed on a group: a required checkbox group says so in its description. $field->required && ! $is_checkbox ? ' aria-required="true"' : '', $is_checkbox && $field->has_flag( 'exclusive' ) ? ' data-tflexforms-exclusive' : '', '' !== $required_id ? '' : '' ); foreach ( $field->choices() as $index => $choice ) { $attrs = array( 'type' => $field->type, 'id' => $common['id'] . '-' . $index, 'name' => $is_checkbox ? $field->name . '[]' : $field->name, 'value' => $choice, 'class' => $common['class'], 'checked' => in_array( $choice, $selected, true ), // A required radio group needs "required" on one input for browser validation. 'required' => ! $is_checkbox && $this->html_required( $field ) && 0 === $index, 'aria-invalid' => $common['aria-invalid'], 'tabindex' => $field->option( 'tabindex' ), ); $html .= self::label_wrap( 'tflexforms-choice', '', esc_html( $choice ), $field->has_flag( 'label_first' ) ); if ( $choice === $free_choice ) { $html .= sprintf( '', esc_attr( $field->name . '_free' ), esc_attr( $common['id'] . '-free' ), esc_attr( $free ), /* translators: %s: choice label, e.g. "Other". */ esc_attr( sprintf( __( '%s (please specify)', 'flexsolutions-form-builder' ), $choice ) ) ); } } return $html . ''; } /** * A label around an input, with the text after it (default) or before it. * * @param string $css_class Label class. * @param string $input_html Input markup (escaped). * @param string $text_html Label text (escaped). * @param bool $label_first Text before the input. */ private static function label_wrap( string $css_class, string $input_html, string $text_html, bool $label_first ): string { return sprintf( '', esc_attr( $css_class ), $label_first ? $text_html . ' ' . $input_html : $input_html . ' ' . $text_html ); } /** * Quiz: a randomly chosen question and an answer box. The hidden index * says which question was shown; answers never appear in the page. * * @param Field $field Field. * @param array $common Shared attributes. */ private function quiz( Field $field, array $common ): string { $questions = $field->quiz_questions(); $index = random_int( 0, count( $questions ) - 1 ); $attrs = $common + array( 'type' => 'text', 'value' => '', 'required' => $this->html_required( $field ), 'aria-required' => 'true', 'autocomplete' => 'off', 'maxlength' => '100', 'size' => $field->option( 'size' ), 'tabindex' => $field->option( 'tabindex' ), ); return sprintf( '%2$s ', esc_attr( $common['id'] . '-question' ), esc_html( $questions[ $index ]['question'] ), self::attributes( $attrs ), esc_attr( Validator::quiz_index_field( $field->name ) ), $index ); } /** * File input. "accept" only guides the file picker; the server checks * every file's size, extension and real content. * * @param Field $field Field. * @param array $common Shared attributes. */ private function file( Field $field, array $common ): string { $types = Uploads::types( $field ); $attrs = $common + array( 'type' => 'file', 'accept' => implode( ',', array_map( static fn( string $t ): string => '.' . $t, $types ) ), 'required' => $this->html_required( $field ), 'aria-required' => $field->required ? 'true' : null, 'tabindex' => $field->option( 'tabindex' ), ); return ''; } /** * Range slider with a live value read-out. * * @param Field $field Field. * @param array $common Shared attributes. * @param string $value Current value. */ private function range( Field $field, array $common, string $value ): string { $min = $field->option( 'min' ) ?? '0'; $max = $field->option( 'max' ) ?? '100'; $value = '' !== $value ? $value : (string) ( ( (float) $min + (float) $max ) / 2 ); $attrs = $common + array( 'type' => 'range', 'value' => $value, 'min' => $min, 'max' => $max, 'step' => $field->option( 'step' ), 'tabindex' => $field->option( 'tabindex' ), ); return sprintf( ' %3$s', self::attributes( $attrs ), esc_attr( (string) $common['id'] ), esc_html( $value ) ); } /** * Single consent checkbox. * * @param Field $field Field. * @param array $common Shared attributes. * @param bool $checked Current state. */ private function acceptance( Field $field, array $common, bool $checked ): string { $attrs = $common + array( 'type' => 'checkbox', 'value' => '1', 'checked' => $checked, 'required' => $this->html_required( $field ), 'aria-required' => $field->required ? 'true' : null, 'tabindex' => $field->option( 'tabindex' ), ); return self::label_wrap( 'tflexforms-acceptance', '', esc_html( $field->label() ), $field->has_flag( 'label_first' ) ); } /** * The id of a field's control (or of its group of controls). * * @param Field $field Field. */ private function control_id( Field $field ): string { return (string) ( $field->option( 'id' ) ?? $this->id_prefix . '-' . $field->name ); } /** * Whether the control gets the HTML "required" attribute. Fields inside * [if] groups never do: without JavaScript every group is shown, and the * browser would refuse to submit until fields of inactive groups are * filled. The server (and tflexforms.js, from the schema) still require * them whenever their group is active. * * @param Field $field Field. */ private function html_required( Field $field ): bool { return $field->required && ! isset( $this->form->conditions()[ $field->name ] ); } /** * CSS classes for a control. * * @param Field $field Field. * @param string $base Base class. */ private function classes( Field $field, string $base ): string { return trim( $base . ' ' . ( $field->option( 'class' ) ?? '' ) ); } /** * Builds an escaped attribute string. true => boolean attribute, * null/false => omitted. * * @param array $attrs Attributes. */ private static function attributes( array $attrs ): string { $out = ''; foreach ( $attrs as $name => $value ) { if ( null === $value || false === $value ) { continue; } $name = preg_replace( '/[^a-z-]/', '', strtolower( (string) $name ) ); $out .= true === $value ? ' ' . $name : sprintf( ' %s="%s"', $name, esc_attr( (string) $value ) ); } return $out; } }