value to prefill (a failed no-JavaScript submission). * @type array $errors Field id => error message. * @type string $message Message to show above the form. * @type bool $success Whether $message reports success. * @type bool $preview Render a draft for the builder's preview; it can't be submitted. * } * @return string HTML. */ public static function render( array $form, array $args = array() ) { $args = array_merge( array( 'values' => array(), 'errors' => array(), 'message' => '', 'success' => false, 'preview' => false, ), $args ); if ( 'published' !== $form['status'] && ! $args['preview'] ) { // Editors see why nothing shows; visitors see nothing. return current_user_can( 'edit_posts' ) ? '

' . esc_html__( 'This form is a draft. Publish it in VuloForm to show it here.', 'vuloform' ) . '

' : ''; } ++self::$instances; $settings = $form['schema']['settings']; $style = $settings['style']; $uid = 'vuloform-' . (int) $form['id'] . '-' . self::$instances; $pages = self::pages( $form['schema']['fields'] ); $has_file = in_array( 'file', array_column( $form['schema']['fields'], 'type' ), true ); $classes = array_filter( array( 'vuloform-wrap', 'vuloform--labels-' . $style['label_position'], 'vuloform--spacing-' . $style['spacing'], 'vuloform--button-' . $style['button_align'], $style['css_class'], ) ); $vars = array_filter( array( '--vf-accent' => $style['accent_color'], '--vf-text' => $style['text_color'], '--vf-font-size' => '' !== $style['font_size'] ? $style['font_size'] . 'px' : '', '--vf-radius' => '' !== $style['radius'] ? $style['radius'] . 'px' : '', ) ); $inline = ''; foreach ( $vars as $name => $value ) { $inline .= $name . ':' . $value . ';'; } // A successful no-JavaScript submission shows the confirmation instead of the form. if ( $args['success'] ) { return sprintf( '
%s
', esc_attr( implode( ' ', $classes ) ), esc_attr( $inline ), wp_kses_post( $args['message'] ) ); } $html = sprintf( '
', esc_attr( implode( ' ', $classes ) ), esc_attr( $uid ), esc_attr( $inline ) ); $html .= sprintf( '
', esc_url( admin_url( 'admin-post.php' ) ), $has_file ? ' enctype="multipart/form-data"' : '', esc_attr( wp_json_encode( self::client_config( $form, $args['preview'] ) ) ) ); if ( count( $pages ) > 1 && 'none' !== $settings['progress'] ) { $html .= self::progress( count( $pages ), $settings['progress'] ); } $html .= sprintf( '', '' !== $args['message'] ? ' vuloform-message--error' : '', '' !== $args['message'] ? '' : ' hidden', esc_html( $args['message'] ) ); foreach ( $pages as $index => $fields ) { $html .= sprintf( '
', $index + 1 ); foreach ( $fields as $field ) { $field_html = self::field( $field, $uid, $args['values'][ $field['key'] ] ?? null, $args['errors'][ $field['id'] ] ?? '' ); /** * Filters one field's HTML. * * @param string $field_html Field HTML. * @param array $field Field definition. * @param array $form Form. */ $html .= apply_filters( 'vuloform_render_field', $field_html, $field, $form ); } $html .= '
'; } if ( ! empty( Utill::settings()['honeypot'] ) ) { // Hidden from people (and from screen readers and tab order), irresistible to bots. $html .= sprintf( '', esc_attr( $uid ), esc_html__( 'Leave this field empty', 'vuloform' ), esc_attr( Spam::HONEYPOT ) ); } if ( ! $args['preview'] && Recaptcha::is_active( $form ) ) { // The form's script fills this in: the v2 checkbox is drawn inside it, a v3 token is put // in the hidden field. Without JavaScript the check can't run, and the visitor is told. $html .= sprintf( '
', esc_attr( Recaptcha::FIELD ), esc_html__( 'This form is protected by Google reCAPTCHA, which needs JavaScript. Please switch JavaScript on to send it.', 'vuloform' ) ); } $html .= ''; $html .= sprintf( '', (int) $form['id'] ); $html .= sprintf( '', esc_attr( $args['preview'] ? '' : Token::issue( $form['id'] ) ) ); $html .= ''; $html .= '
'; $html .= sprintf( '', esc_html( $settings['previous_label'] ) ); $html .= sprintf( '', esc_html( $settings['next_label'] ) ); $html .= sprintf( '', esc_html( $settings['submit_label'] ) ); $html .= '
'; return $html; } /** * Splits the fields into pages at each page break. * * @param array $fields Fields in order. * @return array One list of fields per page. */ public static function pages( array $fields ) { $pages = array( array() ); foreach ( $fields as $field ) { if ( ! Registry::get( $field['type'] ) ) { // Belongs to an extension that is not active. continue; } if ( 'page_break' === $field['type'] ) { $pages[] = array(); } else { $pages[ count( $pages ) - 1 ][] = $field; } } // A break at the very start or two in a row would leave an empty page. return array_values( array_filter( $pages ) ) ? array_values( array_filter( $pages ) ) : array( array() ); } /** * What the browser script needs to know about the form. Notifications, webhooks and anything * else private stay on the server. * * @param array $form Form. * @param bool $preview Whether this is the builder's preview. * @return array */ public static function client_config( array $form, $preview = false ) { $fields = array(); $keep = array( 'id', 'key', 'type', 'required', 'conditions', 'formula', 'decimals', 'prefix', 'min', 'max', 'minlength', 'maxlength', 'max_size_mb', 'max_files', 'allowed_types' ); $dynamic = array(); foreach ( $form['schema']['fields'] as $field ) { if ( Registry::is_input( $field['type'] ) ) { $fields[] = array_intersect_key( $field, array_flip( $keep ) ); // A default that depends on the address bar or the page is worked out again in the // browser, which knows both even on a cached page or another website. $template = DynamicValues::browser_template( $field['default'] ?? '' ); if ( '' !== $template ) { $dynamic[ $field['key'] ] = $template; } } } $settings = $form['schema']['settings']; return array( 'id' => (int) $form['id'], 'preview' => (bool) $preview, // Present only for a form that uses reCAPTCHA; nothing is loaded from Google otherwise. 'recaptcha' => ! $preview && Recaptcha::is_active( $form ) ? Recaptcha::client_config() : null, 'endpoint' => esc_url_raw( rest_url( 'vuloform/v1/public/forms/' . (int) $form['id'] . '/submit' ) ), 'token' => esc_url_raw( rest_url( 'vuloform/v1/public/forms/' . (int) $form['id'] . '/token' ) ), 'progress' => $settings['progress'], 'dynamic' => $dynamic, 'messages' => array_merge( $settings['messages'], array( 'email' => __( 'Enter a valid email address.', 'vuloform' ), 'url' => __( 'Enter a valid web address, starting with https://.', 'vuloform' ), 'number' => __( 'Enter a number.', 'vuloform' ), 'file' => __( 'That file is too large or not an allowed type.', 'vuloform' ), 'network' => __( 'Something went wrong. Please check your connection and try again.', 'vuloform' ), 'sending' => __( 'Sending…', 'vuloform' ), 'preview' => __( 'This is a preview. Nothing was sent.', 'vuloform' ), /* translators: 1: current step, 2: number of steps. */ 'step' => __( 'Step %1$s of %2$s', 'vuloform' ), ) ), 'fields' => $fields, ); } /** * @param int $count Number of pages. * @param string $type steps or bar. * @return string */ private static function progress( $count, $type ) { /* translators: 1: current step, 2: number of steps. */ $label = sprintf( __( 'Step %1$s of %2$s', 'vuloform' ), 1, $count ); $html = sprintf( ''; } /** * @param array $field Field definition. * @param string $uid Form instance id. * @param mixed $value Value to prefill, or null for the field's default. * @param string $error Error to show. * @return string */ private static function field( array $field, $uid, $value, $error ) { $type = $field['type']; $id = $uid . '-' . $field['id']; $name = 'vf[' . $field['key'] . ']'; if ( 'hidden' === $type ) { return sprintf( '', esc_attr( $name ), esc_attr( DynamicValues::resolve( $field['default'] ) ) ); } $classes = array_filter( array( 'vuloform-field', 'vuloform-field--' . $type, 'vuloform-w-' . (int) $field['width'], '' !== $error ? 'vuloform-field--invalid' : '', $field['css_class'], ) ); $open = sprintf( '
', esc_attr( implode( ' ', $classes ) ), esc_attr( $field['id'] ), esc_attr( $field['key'] ) ); switch ( $type ) { case 'heading': return $open . sprintf( '%2$s', (int) $field['level'], esc_html( $field['label'] ) ) . self::description( $field, $id ) . '
'; case 'html': return $open . '
' . wp_kses_post( $field['content'] ) . '
'; case 'divider': return $open . '
'; } $value = null === $value ? DynamicValues::resolve( $field['default'] ?? '' ) : $value; $required = ! empty( $field['required'] ); $describe = trim( ( '' !== $field['description'] ? $id . '-desc ' : '' ) . $id . '-error' ); $common = sprintf( ' aria-describedby="%s"%s%s', esc_attr( $describe ), $required ? ' aria-required="true" required' : '', '' !== $error ? ' aria-invalid="true"' : '' ); $star = $required ? ' ' : ''; $hide = ! empty( $field['hide_label'] ) ? ' vuloform-sr-only' : ''; $label = sprintf( '', $hide, esc_attr( $id ), esc_html( $field['label'] ), $star ); $legend = sprintf( '%s%s', $hide, esc_html( $field['label'] ), $star ); $control = ''; $grouped = false; switch ( $type ) { case 'textarea': $control = sprintf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( $field['placeholder'] ), $field['maxlength'] > 0 ? ' maxlength="' . (int) $field['maxlength'] . '"' : '', $common, esc_textarea( (string) $value ) ); break; case 'select': $control = sprintf( ''; break; case 'radio': case 'checkboxes': $grouped = true; $chosen = is_array( $value ) ? $value : array( (string) $value ); foreach ( $field['options'] as $index => $option ) { $control .= sprintf( '', 'radio' === $type ? 'radio' : 'checkbox', esc_attr( 'radio' === $type ? $name : $name . '[]' ), esc_attr( $option['value'] ), in_array( $option['value'], $chosen, true ) ? ' checked' : '', // One "required" on a checkbox group would demand every box; the script and the server enforce "at least one". 'radio' === $type && $required && 0 === $index ? ' required' : '', esc_html( $option['label'] ) ); } break; case 'consent': $text = '' !== $field['consent_text'] ? wp_kses_post( $field['consent_text'] ) : esc_html( $field['label'] ); $control = sprintf( '', esc_attr( $id ), esc_attr( $name ), checked( '1', (string) $value, false ), $common, $text, $star ); $label = ''; break; case 'name': case 'address': $grouped = true; $labels = Registry::part_labels( $type ); $tokens = array( 'first' => 'given-name', 'last' => 'family-name', 'line1' => 'address-line1', 'line2' => 'address-line2', 'city' => 'address-level2', 'state' => 'address-level1', 'zip' => 'postal-code', 'country' => 'country-name', ); $control .= '
'; foreach ( $field['parts'] as $part ) { $control .= sprintf( '
', esc_attr( $part ), esc_attr( $id ), esc_html( $labels[ $part ] ), esc_attr( $name ), esc_attr( is_array( $value ) ? (string) ( $value[ $part ] ?? '' ) : '' ), esc_attr( $tokens[ $part ] ) ); } $control .= '
'; break; case 'file': $accept = array(); foreach ( $field['allowed_types'] as $extension ) { $accept[] = '.' . $extension; } $control = sprintf( '', esc_attr( $id ), esc_attr( 'vf_file_' . $field['key'] . '[]' ), $field['max_files'] > 1 ? ' multiple' : '', esc_attr( implode( ',', $accept ) ), $common ); $field['description'] = trim( $field['description'] . ' ' . sprintf( /* translators: 1: comma-separated file extensions, 2: size in megabytes. */ __( 'Allowed: %1$s. Up to %2$d MB.', 'vuloform' ), implode( ', ', $field['allowed_types'] ), (int) $field['max_size_mb'] ) ); $describe = $id . '-desc ' . $id . '-error'; break; case 'calculation': $control = sprintf( '%s0', esc_attr( $id ), esc_html( $field['prefix'] ) ); break; default: $types = array( 'email' => 'email', 'phone' => 'tel', 'number' => 'number', 'url' => 'url', 'date' => 'date', 'time' => 'time', ); $extra = ''; if ( 'number' === $type ) { foreach ( array( 'min', 'max', 'step' ) as $attribute ) { $extra .= '' !== $field[ $attribute ] ? sprintf( ' %s="%s"', $attribute, esc_attr( $field[ $attribute ] ) ) : ''; } } if ( 'text' === $type && $field['maxlength'] > 0 ) { $extra .= ' maxlength="' . (int) $field['maxlength'] . '"'; } $autocomplete = array( 'email' => 'email', 'phone' => 'tel', 'url' => 'url', ); $control = sprintf( '', esc_attr( $types[ $type ] ?? 'text' ), esc_attr( $id ), esc_attr( $name ), esc_attr( is_string( $value ) ? $value : '' ), esc_attr( $field['placeholder'] ), isset( $autocomplete[ $type ] ) ? ' autocomplete="' . $autocomplete[ $type ] . '"' : '', $extra, $common ); } $error_html = sprintf( '

%s

', esc_attr( $id ), '' !== $error ? '' : ' hidden', esc_html( $error ) ); if ( $grouped ) { // A group of controls is announced as one question through fieldset and legend. return $open . sprintf( '
', esc_attr( $describe ) ) . $legend . $control . '
' . self::description( $field, $id ) . $error_html . ''; } return $open . $label . $control . self::description( $field, $id ) . $error_html . ''; } /** * @param array $field Field definition. * @param string $id Element id prefix. * @return string */ private static function description( array $field, $id ) { return '' !== $field['description'] ? sprintf( '

%s

', esc_attr( $id ), esc_html( $field['description'] ) ) : ''; } }