Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.82% covered (warning)
77.82%
1572 / 2020
60.56% covered (warning)
60.56%
43 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
Contact_Form_Field
77.90% covered (warning)
77.90%
1572 / 2018
60.56% covered (warning)
60.56%
43 / 71
3488.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
94.17% covered (success)
94.17%
113 / 120
0.00% covered (danger)
0.00%
0 / 1
26.13
 add_error
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_value
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 has_conditional_logic
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validate
55.17% covered (warning)
55.17%
80 / 145
0.00% covered (danger)
0.00%
0 / 1
349.68
 sanitize_text_field
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_option_value
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 render
69.47% covered (warning)
69.47%
66 / 95
0.00% covered (danger)
0.00%
0 / 1
99.07
 get_computed_field_value
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
21
 get_conditional_logic_value
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_filtered_hidden_field_value
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 is_submitted_hidden_field_value
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 render_label
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
19.07
 is_label_hidden_by_block_visibility
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_block_visibility_aria_label
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_hidden_label_aria_label_attr
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_file_dropzone_aria_label
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 render_legend_as_label
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
10.02
 render_input_field
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
8.19
 get_error_div
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_help_text
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 get_format_hint
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_described_by
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_description_parts
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 get_field_descriptions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 set_invalid_message
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 render_email_field
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 render_telephone_field
95.65% covered (success)
95.65%
88 / 92
0.00% covered (danger)
0.00%
0 / 1
9
 render_url_field
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 render_textarea_field
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 render_radio_field
77.53% covered (warning)
77.53%
69 / 89
0.00% covered (danger)
0.00%
0 / 1
38.54
 render_other_input_field
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 render_checkbox_field
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 render_consent_field
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 render_file_field
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
12
 sanitize_file_field_content
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 get_file_field_allowed_html
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
7
 render_hidden_field
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 get_file_field_max_files
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_file_field_max_files_limit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_file_field_max_upload_size
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_file_field_accepted_mime_types
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_file_field_assets
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 get_unauth_endpoint_url
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 render_checkbox_multiple_field
81.82% covered (warning)
81.82%
63 / 77
0.00% covered (danger)
0.00%
0 / 1
28.76
 render_select_field
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
8.05
 get_date_formats
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_date_format
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 render_date_field
91.67% covered (success)
91.67%
66 / 72
0.00% covered (danger)
0.00%
0 / 1
4.01
 render_time_field
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 render_image_select_field
0.00% covered (danger)
0.00%
0 / 128
0.00% covered (danger)
0.00%
0 / 1
1722
 render_number_field
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 render_default_field
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_form_variation_style_properties
93.10% covered (success)
93.10%
54 / 58
0.00% covered (danger)
0.00%
0 / 1
19.12
 render_outline_label
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 render_animated_label
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 render_below_label
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 render_field
90.27% covered (success)
90.27%
102 / 113
0.00% covered (danger)
0.00%
0 / 1
35.07
 get_field_extra
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 maybe_override_type
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 is_field_renderable
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 get_form_style
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 has_inset_label
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 type_has_inset_label
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 render_rating_field
84.29% covered (warning)
84.29%
59 / 70
0.00% covered (danger)
0.00%
0 / 1
11.47
 render_slider_field
93.10% covered (success)
93.10%
54 / 58
0.00% covered (danger)
0.00%
0 / 1
8.02
 enqueue_slider_field_assets
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 get_translatable_countries
100.00% covered (success)
100.00%
241 / 241
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_phone_field_assets
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 trim_image_select_options
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * Contact_Form_Field class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\Forms\Jetpack_Forms;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * Class for the contact-field shortcode.
20 * Parses shortcode to output the contact form field as HTML.
21 * Validates input.
22 */
23class Contact_Form_Field extends Contact_Form_Shortcode {
24
25    /**
26     * Number of files a file upload field accepts when its `maxfiles` attribute says nothing.
27     *
28     * One, so that a field authored before the setting existed keeps behaving exactly as it did.
29     *
30     * @var int
31     */
32    const FILE_FIELD_DEFAULT_MAX_FILES = 1;
33
34    /**
35     * Highest number of files a filter may raise a field to.
36     *
37     * Separate from FILE_FIELD_MAX_FILES_LIMIT, which bounds what an author can choose: a site that
38     * wants more headroom than the editor offers can filter its way up to here, and no further. The
39     * endpoint imposes no count limit of its own that refuses anything â€” its per-site budgets log
40     * and alert but still store the file â€” so this is the only thing standing between a filter and
41     * a single site uploading as much as it likes.
42     *
43     * @var int
44     */
45    const FILE_FIELD_MAX_FILES_ABSOLUTE_LIMIT = 25;
46
47    /**
48     * Highest number of files an author can choose for a field, whatever `maxfiles` asks for.
49     *
50     * This bounds how many files can be attached to a response, not how many bytes reach the site.
51     * Each file is uploaded as soon as the visitor picks it, so by the time anything here runs the
52     * transfer has already happened and the submission carries only references â€” and the upload
53     * endpoint is reachable on its own besides. Read this as a limit on the shape of a response,
54     * and look to the endpoint's own quotas for anything to do with volume.
55     *
56     * Keep in sync with `MAX_FILES_LIMIT` in `blocks/field-file/edit.jsx`, which bounds the editor
57     * control; `test_file_field_ceiling_matches_the_editor_control` fails if they drift.
58     *
59     * @var int
60     */
61    const FILE_FIELD_MAX_FILES_LIMIT = 10;
62
63    /**
64     * Maximum size, in bytes, of a single uploaded file.
65     *
66     * @var int
67     */
68    const FILE_FIELD_MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
69
70    /**
71     * The shortcode name.
72     *
73     * @var string
74     */
75    public $shortcode_name = 'contact-field';
76
77    /**
78     * Field types that never render a style-specific label.
79     *
80     * These have no single labellable input â€” they are groups of inputs, or a
81     * control with its own labelling â€” so every non-default form style falls back
82     * to the plain default label.
83     *
84     * @var string[]
85     */
86    const TYPES_WITHOUT_STYLED_LABEL = array( 'checkbox', 'checkbox-multiple', 'radio', 'consent', 'file' );
87
88    /**
89     * Field types that never render an *inset* (Outlined / Animated) label.
90     *
91     * Their input is not a single text-like box for a label to sit inside, so an
92     * inset label would overlap the control. Styles that render the label outside
93     * the field, such as 'below', still apply â€” unlike TYPES_WITHOUT_STYLED_LABEL,
94     * this only opts out of the inset styles.
95     *
96     * Keep in sync with the editor (`blocks/label/edit.jsx`) and the wrapper
97     * exclusion list in `contact-form/css/grunion.scss`.
98     *
99     * @var string[]
100     */
101    const TYPES_WITHOUT_INSET_LABEL = array( 'slider' );
102
103    /**
104     * The parent form.
105     *
106     * @var Contact_Form
107     */
108    public $form;
109
110    /**
111     * Default or POSTed value.
112     *
113     * @var string|string[]
114     */
115    public $value;
116
117    /**
118     * Is the input valid?
119     *
120     * @var bool
121     */
122    public $error = false;
123
124    /**
125     * Styles to be applied to the field
126     *
127     * @var string
128     */
129    public $block_styles = '';
130
131    /**
132     * Classes to be applied to the field
133     *
134     * @var string
135     */
136    public $field_classes = '';
137
138    /**
139     * Styles to be applied to the field
140     *
141     * @var string
142     */
143    public $field_styles = '';
144
145    /**
146     * Classes to be applied to the field option
147     *
148     * @var string
149     */
150    public $option_classes = '';
151
152    /**
153     * Styles to be applied to the field option
154     *
155     * @var string
156     */
157    public $option_styles = '';
158
159    /**
160     * Classes to be applied to the field
161     *
162     * @var string
163     */
164    public $label_classes = '';
165
166    /**
167     * Styles to be applied to the field
168     *
169     * @var string
170     */
171    public $label_styles = '';
172
173    /**
174     * Input tuple used for the cached hidden-field filter result.
175     *
176     * @var array|null
177     */
178    private $hidden_field_filter_input;
179
180    /**
181     * Cached hidden-field filter result.
182     *
183     * @var mixed
184     */
185    private $hidden_field_filter_value;
186
187    /**
188     * Description markup (help text, format hint, error div) built at the input
189     * site but held back so an inset-label wrapper can emit it outside the
190     * field div. Null when nothing has been deferred.
191     *
192     * Building it once, where the input's aria-describedby is built, is what
193     * keeps the emitted ids and the referenced ids identical.
194     *
195     * @since 8.0.0
196     *
197     * @var string|null
198     */
199    private $deferred_descriptions = null;
200
201    /**
202     * Constructor function.
203     *
204     * @param array        $attributes An associative array of shortcode attributes.  @see shortcode_atts().
205     * @param null|string  $content Null for selfclosing shortcodes.  The inner content otherwise.
206     * @param Contact_Form $form The parent form.
207     */
208    public function __construct( $attributes, $content = null, $form = null ) {
209        $attributes = shortcode_atts(
210            array(
211                'label'                        => null,
212                'togglelabel'                  => null,
213                'type'                         => 'text',
214                'required'                     => false,
215                'requiredtext'                 => null,
216                'requiredindicator'            => true,
217                'options'                      => array(),
218                'optionsdata'                  => array(),
219                'allowother'                   => null,
220                'isother'                      => null,
221                'otherplaceholder'             => null,
222                'id'                           => null,
223                'style'                        => null,
224                'fieldbackgroundcolor'         => null,
225                'buttonbackgroundcolor'        => null,
226                'buttonborderradius'           => null,
227                'buttonborderwidth'            => null,
228                'textcolor'                    => null,
229                'default'                      => null,
230                'values'                       => null,
231                'placeholder'                  => null,
232                'class'                        => null,
233                'width'                        => null,
234                'consenttype'                  => null,
235                'dateformat'                   => null,
236                'helptext'                     => null,
237                'implicitconsentmessage'       => null,
238                'explicitconsentmessage'       => null,
239                'borderradius'                 => null,
240                'borderwidth'                  => null,
241                'lineheight'                   => null,
242                'labellineheight'              => null,
243                'bordercolor'                  => null,
244                'inputcolor'                   => null,
245                'labelcolor'                   => null,
246                'labelfontsize'                => null,
247                'fieldfontsize'                => null,
248                'labelclasses'                 => null,
249                'labelstyles'                  => null,
250                'inputclasses'                 => null,
251                'inputstyles'                  => null,
252                'optionclasses'                => null,
253                'optionstyles'                 => null,
254                'min'                          => null,
255                'max'                          => null,
256                'minlabel'                     => null,
257                'maxlabel'                     => null,
258                'step'                         => null,
259                'maxfiles'                     => null,
260                'fieldwrapperclasses'          => null,
261                'stylevariationattributes'     => array(),
262                'stylevariationclasses'        => null,
263                'stylevariationstyles'         => null,
264                'optionsclasses'               => null,
265                'optionsstyles'                => null,
266                'align'                        => null,
267                'variation'                    => null,
268                'iconstyle'                    => null, // For rating field icon style (lowercase for shortcode compatibility)
269                // full phone field attributes, might become a standalone country list input block
270                'showcountryselector'          => false,
271                'searchplaceholder'            => false,
272                // Image select field attributes
273                'ismultiple'                   => null,
274                'showlabels'                   => null,
275                'issupersized'                 => null,
276                'randomizeoptions'             => null,
277                'showotheroption'              => null,
278                // derived from block metadata for blockVisibility support
279                'labelhiddenbyblockvisibility' => null,
280                // JSON-encoded conditional logic config; decoded below.
281                'conditionallogic'             => null,
282            ),
283            $attributes,
284            'contact-field'
285        );
286
287        if ( ! empty( $attributes['conditionallogic'] ) && is_string( $attributes['conditionallogic'] ) ) {
288            $decoded                        = json_decode( html_entity_decode( $attributes['conditionallogic'], ENT_COMPAT ), true );
289            $attributes['conditionallogic'] = is_array( $decoded ) ? $decoded : null;
290        } elseif ( ! is_array( $attributes['conditionallogic'] ) ) {
291            $attributes['conditionallogic'] = null;
292        }
293
294        // special default for subject field
295        if ( 'subject' === $attributes['type'] && $attributes['default'] === null && $form !== null ) {
296            $attributes['default'] = $form->get_attribute( 'subject' );
297        }
298
299        // allow required=1 or required=true
300        if ( '1' === $attributes['required'] || 'true' === strtolower( $attributes['required'] ) ) {
301            $attributes['required'] = true;
302        } else {
303            $attributes['required'] = false;
304        }
305
306        if ( $attributes['requiredtext'] === null ) {
307            $attributes['requiredtext'] = __( '(required)', 'jetpack-forms' );
308        }
309
310        // parse out comma-separated options list (for selects, radios, and checkbox-multiples)
311        if ( ! empty( $attributes['options'] ) && is_string( $attributes['options'] ) ) {
312            $attributes['options'] = array_map( 'trim', explode( ',', $attributes['options'] ) );
313
314            if ( ! empty( $attributes['values'] ) && is_string( $attributes['values'] ) ) {
315                $attributes['values'] = array_map( 'trim', explode( ',', $attributes['values'] ) );
316            }
317        }
318
319        if ( ! empty( $attributes['optionsdata'] ) ) {
320            $attributes['optionsdata'] = json_decode( html_entity_decode( $attributes['optionsdata'], ENT_COMPAT ), true );
321        }
322
323        // allow boolean values for showcountryselector, only if it's set so we don't pollute other fields attrs
324        if ( isset( $attributes['showcountryselector'] ) ) {
325            if ( true === $attributes['showcountryselector'] || '1' === $attributes['showcountryselector'] || 'true' === strtolower( $attributes['showcountryselector'] ) ) {
326                $attributes['showcountryselector'] = true;
327            } else {
328                $attributes['showcountryselector'] = false;
329            }
330        }
331
332        if ( $form ) {
333            // make a unique field ID based on the label, with an incrementing number if needed to avoid clashes
334            $form_id = $form->get_attribute( 'id' );
335            $id      = $attributes['id'] ?? false;
336
337            $unescaped_label = $this->unesc_attr( $attributes['label'] );
338            $unescaped_label = str_replace( '%', '-', $unescaped_label ); // jQuery doesn't like % in IDs?
339            $unescaped_label = preg_replace( '/[^a-zA-Z0-9.-_:]/', '', $unescaped_label );
340
341            if ( empty( $id ) ) {
342                $id        = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label );
343                $i         = 0;
344                $max_tries = 99;
345                while ( isset( $form->fields[ $id ] ) ) {
346                    ++$i;
347                    $id = sanitize_title_with_dashes( 'g' . $form_id . '-' . $unescaped_label . '-' . $i );
348
349                    if ( $i > $max_tries ) {
350                        break;
351                    }
352                }
353            } elseif ( isset( $form->fields[ $id ] ) ) {
354                // A manually-set field ID (including the Name field's default IDs
355                // such as "name"/"first-name") is carried verbatim when a field is
356                // duplicated or copy/pasted, so two fields can end up sharing an ID.
357                // Duplicate IDs render the same input name: browsers mirror typing
358                // across the fields and only one value survives submission, dropping
359                // the rest from stored responses and emails. Suffix the later
360                // duplicates ("name" -> "name-2" -> "name-3", â€¦) to keep every field
361                // ID unique, matching generateUniqueFormFieldId() on the editor side.
362                // See FORMS-724.
363                $base = $id;
364                $i    = 2;
365                $id   = $base . '-' . $i;
366                while ( isset( $form->fields[ $id ] ) ) {
367                    ++$i;
368                    $id = $base . '-' . $i;
369                }
370            }
371
372            $attributes['id'] = $id;
373        }
374
375        parent::__construct( $attributes, $content );
376
377        // Store parent form
378        $this->form = $form;
379    }
380
381    /**
382     * This field's input is invalid.  Flag as invalid and add an error to the parent form
383     *
384     * @param string $message The error message to display on the form.
385     */
386    public function add_error( $message ) {
387        $this->error = true;
388        $this->form->add_error( $this->get_attribute( 'id' ), $message );
389    }
390
391    /**
392     * Is the field input invalid?
393     *
394     * @see $error
395     *
396     * @return bool
397     */
398    public function is_error() {
399        return $this->error;
400    }
401
402    /**
403     * Check if the field has a value.
404     *
405     * This is used to determine if the field has been filled out by the user.
406     *
407     * @return bool True if the field has a value, false otherwise.
408     */
409    public function has_value() {
410        $field_id    = $this->get_attribute( 'id' );
411        $field_value = isset( $_POST[ $field_id ] ) ? wp_unslash( $_POST[ $field_id ] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- no site changes.
412
413        if ( is_array( $field_value ) ) {
414            if ( empty( $field_value ) ) {
415                return false;
416            }
417            return ! empty( array_filter( $field_value ) );
418        }
419        return ! empty( trim( $field_value ) );
420    }
421
422    /**
423     * Whether this field's visibility is governed by conditional logic.
424     *
425     * @return bool True when the field carries an enabled conditional-logic config.
426     */
427    public function has_conditional_logic() {
428        $logic = $this->get_attribute( 'conditionallogic' );
429
430        return is_array( $logic ) && ! empty( $logic['enabled'] );
431    }
432
433    /**
434     * Validates the form input
435     */
436    public function validate() {
437        // If the field is already invalid, don't validate it again.
438        if ( $this->is_error() ) {
439            return;
440        }
441
442        $field_type = $this->maybe_override_type();
443        // If it's not required, there's nothing to validate
444        if ( ! $this->get_attribute( 'required' ) && ! $this->has_value() ) {
445            return;
446        }
447
448        if ( ! $this->is_field_renderable( $field_type ) ) {
449            return;
450        }
451
452        $field_id    = $this->get_attribute( 'id' );
453        $field_label = $this->get_attribute( 'label' );
454
455        if ( isset( $_POST[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
456            if ( is_array( $_POST[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
457                $field_value = array_map( 'sanitize_text_field', wp_unslash( $_POST[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verification should happen in caller.
458            } else {
459                $field_value = sanitize_text_field( wp_unslash( $_POST[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verification should happen in caller.
460            }
461        } else {
462            $field_value = '';
463        }
464
465        switch ( $field_type ) {
466            case 'url':
467                if ( ! is_string( $field_value ) || empty( $field_value ) || ! preg_match(
468                    // Changes to this regex should be synced with the regex in the render_url_field method of this class as both validate the same input. Note that this regex is in PCRE format.
469                    '%^(?:(?:https?|ftp)://)?(?:\S+(?::\S*)?@|\d{1,3}(?:\.\d{1,3}){3}|(?:(?:[a-z\d\x{00a1}-\x{ffff}]+-?)*[a-z\d\x{00a1}-\x{ffff}]+)(?:\.(?:[a-z\d\x{00a1}-\x{ffff}]+-?)*[a-z\d\x{00a1}-\x{ffff}]+)*(?:\.[a-z\x{00a1}-\x{ffff}]{2,6}))(?::\d+)?(?:[^\s]*)?$%iu',
470                    $field_value
471                ) ) {
472                    /* translators: %s is the name of a form field */
473                    $this->add_error( sprintf( __( '%s: Please enter a valid URL - https://www.example.com.', 'jetpack-forms' ), $field_label ) );
474                }
475                break;
476            case 'email':
477                // Make sure the email address is valid
478                if ( ! is_string( $field_value ) || ! is_email( $field_value ) ) {
479                    /* translators: %s is the name of a form field */
480                    $this->add_error( sprintf( __( '%s requires a valid email address.', 'jetpack-forms' ), $field_label ) );
481                }
482                break;
483            case 'checkbox-multiple':
484                // Check that there is at least one option selected
485                if ( empty( $field_value ) ) {
486                    /* translators: %s is the name of a form field */
487                    $this->add_error( sprintf( __( '%s requires at least one selection.', 'jetpack-forms' ), $field_label ) );
488                } else {
489
490                    $options_data    = (array) $this->get_attribute( 'optionsdata' );
491                    $possible_values = array();
492                    if ( ! empty( $options_data ) ) {
493                        foreach ( $options_data as $option_index => $option ) {
494                            $option_label = isset( $option['label'] ) ? Contact_Form_Plugin::strip_tags( $option['label'] ) : '';
495                            if ( is_string( $option_label ) && '' !== $option_label ) {
496                                $possible_values[] = $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option_label );
497                            }
498                        }
499                    } else {
500                        foreach ( (array) $this->get_attribute( 'options' ) as $option_index => $option ) {
501                            $option = Contact_Form_Plugin::strip_tags( $option );
502                            if ( is_string( $option ) && '' !== $option ) {
503                                $possible_values[] = $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option );
504                            }
505                        }
506                    }
507
508                    $non_empty_options = array_map( array( $this, 'sanitize_text_field' ), $possible_values );
509
510                    foreach ( $field_value  as $field_value_item ) {
511                        if ( ! in_array( $field_value_item, $non_empty_options, true ) ) {
512                            /* translators: %s is the name of a form field */
513                            $this->add_error( sprintf( __( '%s requires at least one selection.', 'jetpack-forms' ), $field_label ) );
514                            break;
515                        }
516                    }
517                }
518                break;
519            case 'radio':
520                // Check that there is at least one option selected
521                if ( empty( $field_value ) ) {
522                    /* translators: %s is the name of a form field */
523                    $this->add_error( sprintf( __( '%s requires at least one selection.', 'jetpack-forms' ), $field_label ) );
524                } else {
525                    // Check that the selected options are valid
526                    $options      = (array) $this->get_attribute( 'options' );
527                    $options_data = (array) $this->get_attribute( 'optionsdata' );
528                    $allow_other  = $this->get_attribute( 'allowother' );
529
530                    if ( ! empty( $options_data ) ) {
531                        $options = array_map(
532                            function ( $option ) {
533                                return $this->sanitize_text_field( trim( $option['label'] ) );
534                            },
535                            $options_data
536                        );
537                    } else {
538                        $options = array_map( array( $this, 'sanitize_text_field' ), $options );
539                    }
540                    $non_empty_options = array_filter(
541                        $options,
542                        function ( $option ) {
543                            return $option !== '';
544                        }
545                    );
546
547                    // Validate Other option values, which may include custom user text.
548                    $is_valid_other = false;
549                    if ( $allow_other ) {
550                        $other_label = null;
551                        foreach ( $options_data as $option ) {
552                            if ( ! empty( $option['isOther'] ) ) {
553                                $other_label = Contact_Form_Plugin::strip_tags( $option['label'] );
554                                break;
555                            }
556                        }
557
558                        if ( ! empty( $other_label ) ) {
559                            $other_prefix = $other_label . ': ';
560
561                            if ( $field_value === $other_label ) {
562                                $is_valid_other = true;
563                            } elseif ( strpos( $field_value, $other_prefix ) === 0 ) {
564                                $custom_text = trim( substr( $field_value, strlen( $other_prefix ) ) );
565
566                                if ( $this->get_attribute( 'required' ) && empty( $custom_text ) ) {
567                                    /* translators: %1$s is the name of a form field, %2$s is the option label */
568                                    $this->add_error( sprintf( __( '%1$s requires a response when "%2$s" is selected.', 'jetpack-forms' ), $field_label, $other_label ) );
569                                    break;
570                                }
571
572                                $is_valid_other = true;
573                            }
574                        }
575                    }
576
577                    if ( ! in_array( $field_value, $non_empty_options, true ) && ! $is_valid_other ) {
578                        /* translators: %s is the name of a form field */
579                        $this->add_error( sprintf( __( '%s requires at least one selection.', 'jetpack-forms' ), $field_label ) );
580                        break;
581                    }
582                }
583                break;
584            case 'image-select':
585                // Check that there is at least one option selected
586                if ( empty( $field_value ) ) {
587                    /* translators: %s is the name of a form field */
588                    $this->add_error( sprintf( __( '%s requires at least one selection.', 'jetpack-forms' ), $field_label ) );
589                } else {
590                    // Check that the selected options are valid
591                    $options      = (array) $this->get_attribute( 'options' );
592                    $options_data = (array) $this->get_attribute( 'optionsdata' );
593
594                    if ( ! empty( $options_data ) ) {
595                        // Extract letters from options_data for validation
596                        $options = array_map(
597                            function ( $option ) {
598                                return sanitize_text_field( trim( $option['letter'] ?? '' ) );
599                            },
600                            $options_data
601                        );
602                    }
603
604                    $non_empty_options = array_filter(
605                        $options,
606                        function ( $option ) {
607                            return $option !== '';
608                        }
609                    );
610
611                    // For single selection (radio), check if the selected value is in the options
612                    if ( ! $this->get_attribute( 'ismultiple' ) ) {
613                        // Decode the JSON response to get the selected value
614                        $decoded_value  = json_decode( $field_value, true );
615                        $selected_value = $decoded_value['selected'] ?? '';
616
617                        if ( ! in_array( $selected_value, $non_empty_options, true ) ) {
618                            /* translators: %s is the name of a form field */
619                            $this->add_error( sprintf( __( '%s requires a valid selection.', 'jetpack-forms' ), $field_label ) );
620                        }
621                    } else {
622                        // For multiple selection (checkbox), check each selected value
623                        foreach ( $field_value as $field_value_item ) {
624                            // Decode the JSON response to get the selected value
625                            $decoded_item   = json_decode( $field_value_item, true );
626                            $selected_value = $decoded_item['selected'] ?? '';
627
628                            if ( ! in_array( $selected_value, $non_empty_options, true ) ) {
629                                /* translators: %s is the name of a form field */
630                                $this->add_error( sprintf( __( '%s requires valid selections.', 'jetpack-forms' ), $field_label ) );
631                                break;
632                            }
633                        }
634                    }
635                }
636                break;
637            case 'number':
638                // Make sure the number address is valid
639                if ( ! is_numeric( $field_value ) ) {
640                    /* translators: %s is the name of a form field */
641                    $this->add_error( sprintf( __( '%s requires a number.', 'jetpack-forms' ), $field_label ) );
642                }
643                break;
644            case 'time':
645                // Make sure the number address is valid
646                if ( ! preg_match( '/^(?:2[0-3]|[01][0-9]):[0-5][0-9]$/', $field_value ) ) {
647                    /* translators: %s is the name of a form field */
648                    $this->add_error( sprintf( __( '%s requires a time', 'jetpack-forms' ), $field_label ) );
649                }
650                break;
651            case 'file':
652                // Make sure the file field is not empty
653                if ( ! is_array( $field_value ) || empty( $field_value[0] ) ) {
654                    /* translators: %s is the name of a form field */
655                    $this->add_error( sprintf( __( '%s requires a file to be uploaded.', 'jetpack-forms' ), $field_label ) );
656                    break;
657                }
658
659                /*
660                 * Nothing enforced the count on this side before. The limit lived only in the
661                 * browser, so a submission assembled by hand could carry as many uploaded file
662                 * IDs as it liked and every one of them would be attached to the response.
663                 */
664                $max_files = $this->get_file_field_max_files();
665
666                if ( count( $field_value ) > $max_files ) {
667                    $this->add_error(
668                        sprintf(
669                            /* translators: 1: the name of a form field, 2: the maximum number of files it accepts. */
670                            _n(
671                                '%1$s accepts at most %2$d file.',
672                                '%1$s accepts at most %2$d files.',
673                                $max_files,
674                                'jetpack-forms'
675                            ),
676                            $field_label,
677                            $max_files
678                        )
679                    );
680                }
681                break;
682            default:
683                // Just check for presence of any text
684                if ( ! is_string( $field_value ) || ! strlen( trim( $field_value ) ) ) {
685                    /* translators: %s is the name of a form field */
686                    $this->add_error( sprintf( __( '%s field is required.', 'jetpack-forms' ), $field_label ) );
687                }
688        }
689    }
690    /**
691     * Sanitize a text field value and html_entity_decode the field.
692     *
693     * @param string $field_value The field value to sanitize.
694     * @return string The sanitized field value.
695     */
696    public function sanitize_text_field( $field_value ) {
697        return sanitize_text_field( html_entity_decode( $field_value, ENT_COMPAT ) );
698    }
699
700    /**
701     * Check the default value for options field
702     *
703     * @param string $value - the value we're checking.
704     * @param int    $index - the index.
705     * @param string $options - default field option.
706     *
707     * @return string
708     */
709    public function get_option_value( $value, $index, $options ) {
710        if ( empty( $value[ $index ] ) ) {
711            return $options;
712        }
713        return $value[ $index ];
714    }
715
716    /**
717     * Outputs the HTML for this form field
718     *
719     * @return string HTML
720     */
721    public function render() {
722
723        $field_id                 = $this->get_attribute( 'id' );
724        $field_type               = $this->maybe_override_type();
725        $field_label              = $this->get_attribute( 'label' );
726        $field_required           = $this->get_attribute( 'required' );
727        $field_required_text      = $this->get_attribute( 'requiredtext' );
728        $field_required_indicator = (bool) $this->get_attribute( 'requiredindicator' );
729        $field_placeholder        = $this->get_attribute( 'placeholder' );
730        $field_width              = $this->get_attribute( 'width' );
731        $class                    = 'date' === $field_type ? 'jp-contact-form-date' : $this->get_attribute( 'class' );
732
733        $label_classes  = $this->get_attribute( 'labelclasses' );
734        $label_styles   = $this->get_attribute( 'labelstyles' );
735        $input_classes  = $this->get_attribute( 'inputclasses' );
736        $input_styles   = $this->get_attribute( 'inputstyles' );
737        $option_classes = $this->get_attribute( 'optionclasses' );
738        $option_styles  = $this->get_attribute( 'optionstyles' );
739
740        $has_block_support_styles = ! empty( $label_classes ) || ! empty( $label_styles ) || ! empty( $input_classes ) || ! empty( $input_styles ) || ! empty( $option_classes ) || ! empty( $option_styles );
741
742        if ( $has_block_support_styles ) {
743            // Do any of the block support classes need to be applied at the field wrapper level? Do we need to make the classes etc filterable as per the field classes?
744
745            // Classes.
746            if ( ! empty( $label_classes ) ) {
747                $this->label_classes .= esc_attr( $label_classes );
748            }
749            if ( ! empty( $input_classes ) ) {
750                $class              .= $class ? ' ' . esc_attr( $input_classes ) : esc_attr( $input_classes );
751                $this->field_classes = $input_classes;
752            }
753            if ( ! empty( $option_classes ) ) {
754                $class               .= $class ? ' ' . esc_attr( $option_classes ) : esc_attr( $option_classes );
755                $this->option_classes = $option_classes;
756            }
757
758            // Styles.
759            if ( ! empty( $label_styles ) ) {
760                $this->label_styles .= esc_attr( $label_styles );
761            }
762            if ( ! empty( $input_styles ) ) {
763                $this->field_styles .= esc_attr( $input_styles );
764            }
765            if ( ! empty( $option_styles ) ) {
766                $this->option_styles .= esc_attr( $option_styles );
767            }
768
769            // For Outline style support.
770            $form_style = $this->get_form_style();
771            if ( 'outlined' === $form_style || 'animated' === $form_style ) {
772                $output_data         = $this->get_form_variation_style_properties( $form_style );
773                $this->block_styles .= esc_attr( $output_data['css_vars'] );
774            }
775        } else {
776            if ( is_numeric( $this->get_attribute( 'borderradius' ) ) ) {
777                $this->block_styles .= '--jetpack--contact-form--border-radius: ' . esc_attr( $this->get_attribute( 'borderradius' ) ) . 'px;';
778                $this->field_styles .= 'border-radius: ' . (int) $this->get_attribute( 'borderradius' ) . 'px;';
779            }
780
781            if ( is_numeric( $this->get_attribute( 'borderwidth' ) ) ) {
782                $this->block_styles .= '--jetpack--contact-form--border-size: ' . esc_attr( $this->get_attribute( 'borderwidth' ) ) . 'px;';
783                $this->field_styles .= 'border-width: ' . (int) $this->get_attribute( 'borderwidth' ) . 'px;';
784            }
785
786            if ( is_numeric( $this->get_attribute( 'lineheight' ) ) ) {
787                $this->block_styles  .= '--jetpack--contact-form--line-height: ' . esc_attr( $this->get_attribute( 'lineheight' ) ) . ';';
788                $this->field_styles  .= 'line-height: ' . (int) $this->get_attribute( 'lineheight' ) . ';';
789                $this->option_styles .= 'line-height: ' . (int) $this->get_attribute( 'lineheight' ) . ';';
790            }
791
792            if ( ! empty( $this->get_attribute( 'bordercolor' ) ) ) {
793                $this->block_styles .= '--jetpack--contact-form--border-color: ' . esc_attr( $this->get_attribute( 'bordercolor' ) ) . ';';
794                $this->field_styles .= 'border-color: ' . esc_attr( $this->get_attribute( 'bordercolor' ) ) . ';';
795            }
796
797            if ( ! empty( $this->get_attribute( 'inputcolor' ) ) ) {
798                $this->block_styles  .= '--jetpack--contact-form--text-color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
799                $this->block_styles  .= '--jetpack--contact-form--button-outline--text-color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
800                $this->field_styles  .= 'color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
801                $this->option_styles .= 'color: ' . esc_attr( $this->get_attribute( 'inputcolor' ) ) . ';';
802            }
803
804            if ( ! empty( $this->get_attribute( 'fieldbackgroundcolor' ) ) ) {
805                $this->block_styles .= '--jetpack--contact-form--input-background: ' . esc_attr( $this->get_attribute( 'fieldbackgroundcolor' ) ) . ';';
806                $this->field_styles .= 'background-color: ' . esc_attr( $this->get_attribute( 'fieldbackgroundcolor' ) ) . ';';
807            }
808
809            if ( ! empty( $this->get_attribute( 'fieldfontsize' ) ) ) {
810                $this->block_styles  .= '--jetpack--contact-form--font-size: ' . esc_attr( $this->get_attribute( 'fieldfontsize' ) ) . ';';
811                $this->field_styles  .= 'font-size: ' . esc_attr( $this->get_attribute( 'fieldfontsize' ) ) . ';';
812                $this->option_styles .= 'font-size: ' . esc_attr( $this->get_attribute( 'fieldfontsize' ) ) . ';';
813            }
814
815            if ( ! empty( $this->get_attribute( 'labelcolor' ) ) ) {
816                $this->label_styles .= 'color: ' . esc_attr( $this->get_attribute( 'labelcolor' ) ) . ';';
817            }
818
819            if ( ! empty( $this->get_attribute( 'labelfontsize' ) ) ) {
820                $this->label_styles .= 'font-size: ' . esc_attr( $this->get_attribute( 'labelfontsize' ) ) . ';';
821            }
822
823            if ( is_numeric( $this->get_attribute( 'labellineheight' ) ) ) {
824                $this->label_styles .= 'line-height: ' . (int) $this->get_attribute( 'labellineheight' ) . ';';
825            }
826        }
827
828        if ( ! empty( $this->get_attribute( 'buttonbackgroundcolor' ) ) ) {
829            $this->block_styles .= '--jetpack--contact-form--button-outline--background-color: ' . esc_attr( $this->get_attribute( 'buttonbackgroundcolor' ) ) . ';';
830        }
831        if ( is_numeric( $this->get_attribute( 'buttonborderradius' ) ) ) {
832            $this->block_styles .= '--jetpack--contact-form--button-outline--border-radius: ' . esc_attr( $this->get_attribute( 'buttonborderradius' ) ) . 'px;';
833        }
834        if ( is_numeric( $this->get_attribute( 'buttonborderwidth' ) ) ) {
835            $this->block_styles .= '--jetpack--contact-form--button-outline--border-size: ' . esc_attr( $this->get_attribute( 'buttonborderwidth' ) ) . 'px;';
836
837        }
838
839        if ( ! empty( $field_width ) && ! $this->has_inset_label() ) {
840            $class .= ' grunion-field-width-' . $field_width;
841        }
842
843        /**
844         * Filters the "class" attribute of the contact form input
845         *
846         * @module contact-form
847         *
848         * @since 6.6.0
849         *
850         * @param string $class Additional CSS classes for input class attribute.
851         */
852        $field_class = apply_filters( 'jetpack_contact_form_input_class', $class );
853
854        $this->value = $this->get_computed_field_value( $field_type, $field_id );
855
856        $field_value = Contact_Form_Plugin::strip_tags( $this->value );
857        $field_label = Contact_Form_Plugin::strip_tags( $field_label );
858
859        $extra_attrs = array();
860
861        if ( $field_type === 'number' || $field_type === 'slider' ) {
862            if ( is_numeric( $this->get_attribute( 'min' ) ) ) {
863                $extra_attrs['min'] = $this->get_attribute( 'min' );
864            }
865            if ( is_numeric( $this->get_attribute( 'max' ) ) ) {
866                $extra_attrs['max'] = $this->get_attribute( 'max' );
867            }
868            if ( is_numeric( $this->get_attribute( 'step' ) ) ) {
869                $extra_attrs['step'] = $this->get_attribute( 'step' );
870            }
871        }
872
873        if ( $field_type === 'slider' ) {
874            $minlabel = $this->get_attribute( 'minlabel' );
875            $maxlabel = $this->get_attribute( 'maxlabel' );
876            if ( null !== $minlabel && '' !== $minlabel ) {
877                $extra_attrs['minLabel'] = $minlabel;
878            }
879            if ( null !== $maxlabel && '' !== $maxlabel ) {
880                $extra_attrs['maxLabel'] = $maxlabel;
881            }
882        }
883
884        $rendered_field = $this->render_field( $field_type, $field_id, $field_label, $field_value, $field_class, $field_placeholder, $field_required, $field_required_text, $extra_attrs, $field_required_indicator );
885
886        /**
887         * Filter the HTML of the Contact Form.
888         *
889         * @module contact-form
890         *
891         * @since 2.6.0
892         *
893         * @param string $rendered_field Contact Form HTML output.
894         * @param string $field_label Field label.
895         * @param int|null $id Post ID.
896         */
897        return apply_filters( 'grunion_contact_form_field_html', $rendered_field, $field_label, ( in_the_loop() ? get_the_ID() : null ) );
898    }
899    /**
900     * Returns the computed field value for a field. It uses the POST, GET, Logged in data.
901     *
902     * @module contact-form
903     *
904     * @param string $field_type The field type.
905     * @param string $field_id The field id.
906     *
907     * @return string
908     */
909    public function get_computed_field_value( $field_type, $field_id ) {
910        global $current_user, $user_identity;
911        // Use the POST Field if it is available.
912        if ( isset( $_POST[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
913            if ( is_array( $_POST[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
914                return array_map( 'sanitize_textarea_field', wp_unslash( $_POST[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
915            }
916
917            return sanitize_textarea_field( wp_unslash( $_POST[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
918        }
919
920        // Explicit consent never renders checked, so a missing POST value must remain empty
921        // rather than falling through to a query-string or configured default.
922        if ( 'consent' === $field_type && 'explicit' === $this->get_attribute( 'consenttype' ) ) {
923            return '';
924        }
925
926        // Checkbox controls are omitted from the request when unchecked. During an actual
927        // submission that absence is the submitted value; falling through to a query-string
928        // or configured default would silently check the field again.
929        $is_checkbox_control = in_array( $field_type, array( 'checkbox', 'checkbox-multiple' ), true );
930        if ( $is_checkbox_control && $this->form && $this->form->is_current_submission() ) {
931            return '';
932        }
933
934        // Implicit consent renders as a hidden input with this value, so its computed value
935        // must match what the browser will submit from the first render onward.
936        if ( 'consent' === $field_type && 'explicit' !== $this->get_attribute( 'consenttype' ) ) {
937            return __( 'Yes', 'jetpack-forms' );
938        }
939
940        // Use the GET Field if it is available.
941        if ( isset( $_GET[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
942            if ( is_array( $_GET[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
943                return array_map( 'sanitize_textarea_field', wp_unslash( $_GET[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
944            }
945
946            return sanitize_textarea_field( wp_unslash( $_GET[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
947        }
948
949        if ( ! is_user_logged_in() ) {
950            return $this->get_attribute( 'default' );
951        }
952
953        /**
954         * Allow third-party tools to prefill the contact form with the user's details when they're logged in.
955         *
956         * @module contact-form
957         *
958        * @since 3.2.0
959        *
960        * @param bool false Should the Contact Form be prefilled with your details when you're logged in. Default to false.
961        */
962        $filter_value = apply_filters( 'jetpack_auto_fill_logged_in_user', false );
963        if ( ( ! current_user_can( 'manage_options' ) && ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) || $filter_value ) {
964            switch ( $field_type ) {
965                case 'email':
966                    return $current_user->data->user_email;
967
968                case 'name':
969                    return ! empty( $user_identity ) ? $user_identity : $current_user->data->display_name;
970
971                case 'url':
972                    return $current_user->data->user_url;
973            }
974        }
975
976        return $this->get_attribute( 'default' );
977    }
978
979    /**
980     * Get the field value used to resolve conditional logic.
981     *
982     * Hidden-field filters affect the value rendered into the browser, so apply the same
983     * filter before the server evaluates a rule that uses a hidden field as its subject.
984     *
985     * @return string|array Field value.
986     */
987    public function get_conditional_logic_value() {
988        $field_type = $this->get_attribute( 'type' );
989        $field_id   = $this->get_attribute( 'id' );
990        $value      = $this->get_computed_field_value( $field_type, $field_id );
991
992        if ( 'hidden' === $field_type && ! $this->is_submitted_hidden_field_value( $field_id ) ) {
993            $value = $this->get_filtered_hidden_field_value( $value, $this->get_attribute( 'label' ), $field_id );
994        }
995
996        return $value;
997    }
998
999    /**
1000     * Filter the value of a hidden field once per input tuple.
1001     *
1002     * Rendering and conditional logic must use the exact same filtered value. Caching also
1003     * prevents stateful filters from returning a different value on their second invocation.
1004     *
1005     * @param mixed  $value The value of the hidden field.
1006     * @param string $label The label of the hidden field.
1007     * @param string $id    The ID of the hidden field.
1008     * @return mixed The modified value of the hidden field.
1009     */
1010    private function get_filtered_hidden_field_value( $value, $label, $id ) {
1011        $input = array( $value, $label, $id );
1012
1013        if ( $input === $this->hidden_field_filter_input ) {
1014            return $this->hidden_field_filter_value;
1015        }
1016
1017        /**
1018         * Filter the value of the hidden field.
1019         *
1020         * @since 6.3.0
1021         *
1022         * @param string $value The value of the hidden field.
1023         * @param string $label The label of the hidden field.
1024         * @param string $id The ID of the hidden field.
1025         */
1026        $this->hidden_field_filter_input = $input;
1027        $this->hidden_field_filter_value = apply_filters( 'jetpack_forms_hidden_field_value', $value, $label, $id );
1028
1029        return $this->hidden_field_filter_value;
1030    }
1031
1032    /**
1033     * Whether a hidden value came from a matching form submission.
1034     *
1035     * Hidden values rendered into the browser have already passed through the hidden-field
1036     * filter, so their submitted values must not be filtered a second time.
1037     *
1038     * @param string $id The ID of the hidden field.
1039     * @return bool Whether the submitted value should be treated as already filtered.
1040     */
1041    private function is_submitted_hidden_field_value( $id ) {
1042        return isset( $_POST[ $id ] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
1043            && $this->form
1044            && $this->form->is_current_submission();
1045    }
1046
1047    /**
1048     * Return the HTML for the label.
1049     *
1050     * @param string $type - the field type.
1051     * @param int    $id - the ID.
1052     * @param string $label - the label.
1053     * @param bool   $required - if the field is marked as required.
1054     * @param string $required_field_text - the text in the required text field.
1055     * @param array  $extra_attrs Array of key/value pairs to append as attributes to the element.
1056     * @param bool   $always_render - if the label should always be shown.
1057     * @param bool   $required_indicator Whether to display the required indicator.
1058     *
1059     * @return string HTML
1060     */
1061    public function render_label( $type, $id, $label, $required, $required_field_text, $extra_attrs = array(), $always_render = false, $required_indicator = true ) {
1062        if ( $this->attributes['labelhiddenbyblockvisibility'] ) {
1063            return '';
1064        }
1065        $form_style = $this->get_form_style();
1066
1067        if ( ! empty( $form_style ) && $form_style !== 'default' ) {
1068            if ( ! in_array( $type, self::TYPES_WITHOUT_STYLED_LABEL, true ) ) {
1069                switch ( $form_style ) {
1070                    case 'outlined':
1071                        if ( $this->type_has_inset_label( $type ) ) {
1072                            return $this->render_outline_label( $id, $label, $required, $required_field_text, $required_indicator );
1073                        }
1074                        break;
1075                    case 'animated':
1076                        if ( $this->type_has_inset_label( $type ) ) {
1077                            return $this->render_animated_label( $id, $label, $required, $required_field_text, $required_indicator );
1078                        }
1079                        break;
1080                    case 'below':
1081                        return $this->render_below_label( $id, $label, $required, $required_field_text, $required_indicator );
1082                }
1083            }
1084
1085            if ( ! $always_render ) {
1086                return '';
1087            }
1088        }
1089
1090        if ( ! empty( $this->label_styles ) ) {
1091            $extra_attrs['style'] = $this->label_styles;
1092        }
1093
1094        $type_class = $type ? ' ' . $type : '';
1095
1096        $extra_attrs['class'] = "grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' );
1097
1098        if ( ! empty( $this->label_classes ) ) {
1099            $extra_attrs['class'] .= ' ' . $this->label_classes;
1100        }
1101
1102        $extra_attrs_string = '';
1103
1104        foreach ( $extra_attrs as $attr => $val ) {
1105            $extra_attrs_string .= sprintf( '%s="%s" ', esc_attr( $attr ), esc_attr( $val ) );
1106        }
1107
1108        $type_class = $type ? ' ' . $type : '';
1109        return "<label
1110                for='" . esc_attr( $id ) . "' "
1111                . $extra_attrs_string
1112                . '>'
1113                . wp_kses_post( $label )
1114                . ( $required && $required_indicator ? '<span class="grunion-label-required" aria-hidden="true">' . $required_field_text . '</span>' : '' ) .
1115            "</label>\n";
1116    }
1117
1118    /**
1119     * Whether the field's label is hidden by block visibility in any mode.
1120     *
1121     * Two modes hide a label: "hide everywhere" (labelhiddenbyblockvisibility,
1122     * the label is not rendered at all) and per-viewport (the label is rendered
1123     * but carries a wp-block-hidden-{viewport} class that display:none's it on
1124     * that viewport). Either way the label is absent from the accessibility tree
1125     * where hidden, so the field needs an aria-label fallback to keep a name.
1126     *
1127     * The visibility control now only survives on the label block (see
1128     * FORMS-694), so this is the single place that decides "is the label hidden?"
1129     *
1130     * @return bool
1131     */
1132    private function is_label_hidden_by_block_visibility() {
1133        if ( $this->get_attribute( 'labelhiddenbyblockvisibility' ) ) {
1134            return true;
1135        }
1136
1137        return strpos( (string) $this->get_attribute( 'labelclasses' ), 'wp-block-hidden-' ) !== false;
1138    }
1139
1140    /**
1141     * The accessible name to fall back to when the label is hidden by block
1142     * visibility. Prefers the label text (it matches what sighted users see on
1143     * viewports where the label is visible, for the per-viewport case) and only
1144     * falls back to a secondary name when there is no label. See FORMS-694.
1145     *
1146     * @param string|null $fallback Secondary name to use when there is no label.
1147     *                              Must be a raw string, not a built attribute
1148     *                              fragment. Defaults to the raw placeholder.
1149     * @return string
1150     */
1151    private function get_block_visibility_aria_label( $fallback = null ) {
1152        $label = Contact_Form_Plugin::strip_tags( (string) $this->get_attribute( 'label' ) );
1153        if ( '' !== $label ) {
1154            return $label;
1155        }
1156
1157        if ( null === $fallback ) {
1158            $fallback = $this->get_attribute( 'placeholder' );
1159        }
1160
1161        return Contact_Form_Plugin::strip_tags( (string) $fallback );
1162    }
1163
1164    /**
1165     * Return an ` aria-label='…'` attribute string when the field's label is
1166     * hidden by block visibility (full or per-viewport), or '' otherwise.
1167     *
1168     * A hidden label (removed on full-hide, display:none per-viewport) is absent
1169     * from the a11y tree, so every field that renders its own label â€” single
1170     * inputs and the grouped <fieldset> legend alike â€” needs this fallback to
1171     * keep an accessible name. Shared by all paths so none get missed. The
1172     * attribute is skipped when there is no name to give. See FORMS-694.
1173     *
1174     * @param string|null $fallback Secondary name when there is no label (raw
1175     *                              string, not a built attribute fragment).
1176     * @return string
1177     */
1178    private function get_hidden_label_aria_label_attr( $fallback = null ) {
1179        if ( ! $this->is_label_hidden_by_block_visibility() ) {
1180            return '';
1181        }
1182
1183        $name = $this->get_block_visibility_aria_label( $fallback );
1184        if ( '' === $name ) {
1185            return '';
1186        }
1187
1188        return " aria-label='" . esc_attr( $name ) . "'";
1189    }
1190
1191    /**
1192     * Accessible name for the file field's dropzone <div role="button">.
1193     *
1194     * The dropzone is the field's interactive control, but its name is otherwise
1195     * the generic "Select a file to upload.", so two hidden-label upload fields
1196     * would be announced identically. When the label is hidden (full or
1197     * per-viewport), prefix the field name â€” the same fallback the other single
1198     * inputs use. Falls back to the plain instruction when the label is visible
1199     * or there is no name to give. See FORMS-694.
1200     *
1201     * @param int $max_files How many files the field accepts.
1202     *
1203     * @return string
1204     */
1205    private function get_file_dropzone_aria_label( $max_files ) {
1206        /*
1207         * The count belongs in the accessible name rather than only in the visible dropzone text,
1208         * which is an inner block the author writes and may leave saying "a file" for a field that
1209         * takes several.
1210         */
1211        $select_file_text = $max_files > 1
1212            ? sprintf(
1213                /* translators: %d: the maximum number of files the field accepts. */
1214                _n(
1215                    'Select up to %d file to upload.',
1216                    'Select up to %d files to upload.',
1217                    $max_files,
1218                    'jetpack-forms'
1219                ),
1220                $max_files
1221            )
1222            : __( 'Select a file to upload.', 'jetpack-forms' );
1223
1224        if ( ! $this->is_label_hidden_by_block_visibility() ) {
1225            return $select_file_text;
1226        }
1227
1228        $hidden_name = $this->get_block_visibility_aria_label();
1229        if ( '' === $hidden_name ) {
1230            return $select_file_text;
1231        }
1232
1233        return sprintf(
1234            /* translators: 1: the form field's label, 2: the "Select a file to upload." instruction. */
1235            _x( '%1$s: %2$s', 'file upload field accessible name', 'jetpack-forms' ),
1236            $hidden_name,
1237            $select_file_text
1238        );
1239    }
1240
1241    /**
1242     * Return the HTML for a legend that shares the same style as a label.
1243     *
1244     * @param string $type - the field type.
1245     * @param int    $id - the ID.
1246     * @param string $legend - the legend.
1247     * @param bool   $required - if the field is marked as required.
1248     * @param string $required_field_text - the text in the required text field.
1249     * @param array  $extra_attrs Array of key/value pairs to append as attributes to the element.
1250     * @param bool   $required_indicator Whether to display the required indicator.
1251     *
1252     * @return string HTML
1253     */
1254    public function render_legend_as_label( $type, $id, $legend, $required, $required_field_text, $extra_attrs = array(), $required_indicator = true ) {
1255        // Full-hide via blockVisibility, mirroring render_label(). Grouped fields
1256        // (radio, checkbox-multiple, image-select, rating) render their label as a
1257        // legend, so they need the same guard. Per-viewport hiding still works via
1258        // the label_classes applied below.
1259        if ( $this->attributes['labelhiddenbyblockvisibility'] ) {
1260            return '';
1261        }
1262        if ( ! empty( $this->label_styles ) ) {
1263            $extra_attrs['style'] = $this->label_styles;
1264        }
1265
1266        $type_class           = $type ? ' ' . $type : '';
1267        $extra_attrs['class'] = "grunion-field-label{$type_class}" . ( $this->is_error() ? ' form-error' : '' );
1268
1269        if ( ! empty( $this->label_classes ) ) {
1270            $extra_attrs['class'] .= ' ' . $this->label_classes;
1271        }
1272
1273        $extra_attrs_string = '';
1274        if ( is_array( $extra_attrs ) ) {
1275            foreach ( $extra_attrs as $attr => $val ) {
1276                $extra_attrs_string .= sprintf( '%s="%s" ', esc_attr( $attr ), esc_attr( $val ) );
1277            }
1278        }
1279
1280        return '<legend '
1281                . $extra_attrs_string
1282                . '>'
1283                . '<span class="grunion-label-text">' . wp_kses_post( $legend ) . '</span>'
1284                . ( $required && $required_indicator ? '<span class="grunion-label-required">' . $required_field_text . '</span>' : '' )
1285                . "</legend>\n";
1286    }
1287
1288    /**
1289     * Return the HTML for the input field.
1290     *
1291     * @param string $type - the field type.
1292     * @param int    $id - the ID.
1293     * @param string $value - the value of the field.
1294     * @param string $class - the field class.
1295     * @param string $placeholder - the field placeholder content.
1296     * @param bool   $required - if the field is marked as required.
1297     * @param array  $extra_attrs Array of key/value pairs to append as attributes to the element.
1298     *
1299     * @return string HTML
1300     */
1301    public function render_input_field( $type, $id, $value, $class, $placeholder, $required, $extra_attrs = array() ) {
1302        if ( ! is_string( $value ) ) {
1303            $value = '';
1304        }
1305
1306        $extra_attrs_string = '';
1307
1308        if ( ! empty( $this->field_styles ) ) {
1309            $extra_attrs['style'] = $this->field_styles;
1310        }
1311
1312        if ( is_array( $extra_attrs ) && ! empty( $extra_attrs ) ) {
1313            foreach ( $extra_attrs as $attr => $val ) {
1314                $extra_attrs_string .= sprintf( '%s="%s" ', esc_attr( $attr ), esc_attr( $val ) );
1315            }
1316        }
1317
1318        // this is a hack for Firefox to prevent users from falsely entering a something other than a number into a number field.
1319        if ( $type === 'number' ) {
1320            $extra_attrs_string .= " data-wp-on--keypress='actions.handleNumberKeyPress' ";
1321        }
1322
1323        $extra_attrs_string .= $this->get_hidden_label_aria_label_attr();
1324
1325        return "<input
1326                    type='" . esc_attr( $type ) . "'
1327                    name='" . esc_attr( $id ) . "'
1328                    id='" . esc_attr( $id ) . "'
1329                    value='" . esc_attr( $value ) . "'
1330
1331                    data-wp-bind--aria-invalid='state.fieldAriaInvalid'
1332                    data-wp-bind--value='state.getFieldValue'
1333                    aria-describedby='" . esc_attr( $this->get_described_by( $id, $type ) ) . "'
1334                    data-wp-on--input='actions.onFieldChange'
1335                    data-wp-on--blur='actions.onFieldBlur'
1336                    data-wp-class--has-value='state.hasFieldValue'
1337
1338                    " . $class . $placeholder . '
1339                    ' . ( $required ? "required='true' aria-required='true' " : '' ) .
1340                    $extra_attrs_string .
1341                    " />\n " . $this->get_field_descriptions( $id, $type ) . " \n";
1342    }
1343
1344    /**
1345     * Return the HTML for the error div.
1346     *
1347     * @param string $id - the field ID.
1348     * @param string $type - the field type.
1349     * @param bool   $override_render - if the error div should be rendered even if the label is inset.
1350     *
1351     * @return string HTML
1352     */
1353    private function get_error_div( $id, $type, $override_render = false ) {
1354
1355        if ( ! $override_render && $this->has_inset_label() ) {
1356            return '';
1357        }
1358        return '
1359            <div id="' . esc_attr( $id ) . '-' . esc_attr( $type ) . '-error" class="contact-form__input-error" data-wp-class--has-errors="state.fieldHasErrors">
1360                <span class="contact-form__warning-icon" aria-hidden="true">
1361                    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1362                        <path d="M8.50015 11.6402H7.50015V10.6402H8.50015V11.6402Z" />
1363                        <path d="M7.50015 9.64018H8.50015V6.30684H7.50015V9.64018Z" />
1364                        <path fill-rule="evenodd" clip-rule="evenodd" d="M6.98331 3.0947C7.42933 2.30177 8.57096 2.30177 9.01698 3.09469L13.8771 11.7349C14.3145 12.5126 13.7525 13.4735 12.8602 13.4735H3.14004C2.24774 13.4735 1.68575 12.5126 2.12321 11.7349L6.98331 3.0947ZM8.14541 3.58496C8.08169 3.47168 7.9186 3.47168 7.85488 3.58496L2.99478 12.2251C2.93229 12.3362 3.01257 12.4735 3.14004 12.4735H12.8602C12.9877 12.4735 13.068 12.3362 13.0055 12.2251L8.14541 3.58496Z" />
1365                    </svg>
1366                </span>
1367                <span data-wp-text="state.errorMessage" id="' . esc_attr( $id ) . '-' . esc_attr( $type ) . '-error-message"></span>
1368            </div>';
1369    }
1370
1371    /**
1372     * The author-supplied help text for this field, or null when unset.
1373     *
1374     * @since 8.0.0
1375     *
1376     * @return string|null
1377     */
1378    private function get_help_text() {
1379        $help_text = $this->get_attribute( 'helptext' );
1380
1381        if ( ! is_string( $help_text ) ) {
1382            return null;
1383        }
1384
1385        $help_text = trim( $help_text );
1386
1387        if ( $help_text === '' ) {
1388            return null;
1389        }
1390
1391        // Contact_Form::esc_shortcode_val() encodes `,` `[` `]` `\` as decimal
1392        // entities so they survive the shortcode parser, but unesc_attr() only
1393        // decodes the hex forms. Other consumers emit through wp_kses_post() or
1394        // into an attribute, so the browser decodes whatever is left over; this
1395        // is the first to emit through esc_html() into a text node, where the
1396        // leftover `&#044;` would be escaped again and shown to the visitor.
1397        // Safe to decode here: the value has already been through
1398        // unesc_attr()'s strip_tags(), and output is still esc_html()'d.
1399        return html_entity_decode( $help_text, ENT_QUOTES );
1400    }
1401
1402    /**
1403     * The format instruction for date fields, or null for every other type.
1404     *
1405     * Note this keys off the field's own `type` attribute, not the type passed
1406     * to the render helpers â€” the date field renders a `text` input.
1407     *
1408     * @since 8.0.0
1409     *
1410     * @return string|null
1411     */
1412    private function get_format_hint() {
1413        if ( $this->get_attribute( 'type' ) !== 'date' ) {
1414            return null;
1415        }
1416
1417        $formats = self::get_date_formats();
1418
1419        return $formats[ $this->get_date_format() ] ?? null;
1420    }
1421
1422    /**
1423     * The aria-describedby value for a field's input.
1424     *
1425     * The error message comes first so screen readers announce it before the
1426     * advisory text; its span is empty until there is an error, so it costs
1427     * nothing the rest of the time.
1428     *
1429     * @since 8.0.0
1430     *
1431     * @param string $id   - the field ID.
1432     * @param string $type - the description type (matches the emitted element ids).
1433     *
1434     * @return string
1435     */
1436    private function get_described_by( $id, $type ) {
1437        $ids = array( $id . '-' . $type . '-error-message' );
1438
1439        foreach ( $this->get_description_parts( $id, $type ) as $part ) {
1440            $ids[] = $part['id'];
1441        }
1442
1443        return implode( ' ', $ids );
1444    }
1445
1446    /**
1447     * The advisory descriptions attached to a field, in DOM order.
1448     *
1449     * Single source for both the ids aria-describedby references and the
1450     * markup that carries them â€” building those separately is how they drift,
1451     * and a describedby pointing at an id that was never emitted is exactly
1452     * the failure this class already fixes elsewhere.
1453     *
1454     * @since 8.0.0
1455     *
1456     * @param string $id   - the field ID.
1457     * @param string $type - the description type (matches get_described_by()).
1458     *
1459     * @return array List of array( 'id' => string, 'class' => string, 'text' => string ).
1460     */
1461    private function get_description_parts( $id, $type ) {
1462        $parts     = array();
1463        $help_text = $this->get_help_text();
1464
1465        if ( $help_text !== null ) {
1466            $parts[] = array(
1467                'id'    => $id . '-' . $type . '-help',
1468                'class' => 'contact-form__field-help',
1469                'text'  => $help_text,
1470            );
1471        }
1472
1473        $format_hint = $this->get_format_hint();
1474
1475        if ( $format_hint !== null ) {
1476            $parts[] = array(
1477                'id'    => $id . '-' . $type . '-format',
1478                'class' => 'contact-form__field-format',
1479                'text'  => $format_hint,
1480            );
1481        }
1482
1483        return $parts;
1484    }
1485
1486    /**
1487     * Return the HTML for a field's descriptions: help text, format hint, and
1488     * the error div, in that DOM order.
1489     *
1490     * These travel together because inset-label form styles render them outside
1491     * the field wrapper. In that case the markup is deferred to
1492     * $this->deferred_descriptions and render_field() emits it, so it is only
1493     * ever built here â€” with the same $type the input used for its
1494     * aria-describedby.
1495     *
1496     * @since 8.0.0
1497     *
1498     * @param string $id   - the field ID.
1499     * @param string $type - the description type (matches get_described_by()).
1500     *
1501     * @return string HTML, or an empty string when the markup is deferred.
1502     */
1503    private function get_field_descriptions( $id, $type ) {
1504        $hints = '';
1505
1506        // Deliberately spans, not paragraphs: themes style <p> (margins, size,
1507        // color) and we would have to fight those rules on every theme.
1508        foreach ( $this->get_description_parts( $id, $type ) as $part ) {
1509            $hints .= '<span id="' . esc_attr( $part['id'] ) . '" class="' . esc_attr( $part['class'] ) . '">'
1510                . esc_html( $part['text'] ) . '</span>';
1511        }
1512
1513        $descriptions = '';
1514
1515        if ( $hints !== '' ) {
1516            $descriptions .= '<div class="contact-form__field-hints">' . $hints . '</div>';
1517        }
1518
1519        // Force the error div to build even for inset labels â€” the deferral
1520        // below decides where it lands.
1521        $descriptions .= $this->get_error_div( $id, $type, true );
1522
1523        if ( $this->has_inset_label() ) {
1524            $this->deferred_descriptions = $descriptions;
1525            return '';
1526        }
1527
1528        return $descriptions;
1529    }
1530
1531    /**
1532     * Set the invalid message for specific field types.
1533     *
1534     * @param string $type - the field type.
1535     * @param string $message - the message to display.
1536     *
1537     * @return void
1538     */
1539    private function set_invalid_message( $type, $message ) {
1540        wp_interactivity_config(
1541            'jetpack/form',
1542            array(
1543                'error_types' => array(
1544                    'invalid_' . $type => $message,
1545                ),
1546            )
1547        );
1548    }
1549
1550    /**
1551     * Return the HTML for the email field.
1552     *
1553     * @param int    $id - the ID.
1554     * @param string $label - the label.
1555     * @param string $value - the value of the field.
1556     * @param string $class - the field class.
1557     * @param bool   $required - if the field is marked as required.
1558     * @param string $required_field_text - the text in the required text field.
1559     * @param string $placeholder - the field placeholder content.
1560     * @param bool   $required_indicator Whether to display the required indicator.
1561     *
1562     * @return string HTML
1563     */
1564    public function render_email_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $required_indicator = true ) {
1565        $this->set_invalid_message( 'email', __( 'Please enter a valid email address', 'jetpack-forms' ) );
1566        $field  = $this->render_label( 'email', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
1567        $field .= $this->render_input_field( 'email', $id, $value, $class, $placeholder, $required );
1568        return $field;
1569    }
1570
1571    /**
1572     * Return the HTML for the telephone field.
1573     *
1574     * @param int    $id - the ID.
1575     * @param string $label - the label.
1576     * @param string $value - the value of the field.
1577     * @param string $class - the field class.
1578     * @param bool   $required - if the field is marked as required.
1579     * @param string $required_field_text - the text in the required text field.
1580     * @param string $placeholder - the field placeholder content.
1581     * @param bool   $required_indicator Whether to display the required indicator.
1582     *
1583     * @return string HTML
1584     */
1585    public function render_telephone_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $required_indicator = true ) {
1586        $show_country_selector = $this->get_attribute( 'showcountryselector' );
1587        $default_country       = $this->get_attribute( 'default' );
1588        $search_placeholder    = $this->get_attribute( 'searchplaceholder' );
1589
1590        if ( ! $show_country_selector ) {
1591            // old telephone field treatment
1592            $this->set_invalid_message( 'telephone', __( 'Please enter a valid phone number', 'jetpack-forms' ) );
1593            $label = $this->render_label( 'telephone', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
1594            $field = $this->render_input_field( 'tel', $id, $value, $class, $placeholder, $required );
1595            return $label . $field;
1596        }
1597
1598        if ( empty( $search_placeholder ) ) {
1599            $search_placeholder = __( 'Search countries…', 'jetpack-forms' );
1600        }
1601
1602        $this->enqueue_phone_field_assets();
1603
1604        // $class is ill-formed, so we need to fix it
1605        // Strip 'class=' and quotes to get just the class names
1606        $class_names = preg_replace( "/^class=['\"]([^'\"]*)['\"].*$/", '$1', $class );
1607        // somehow we are getting the class jetpack-field__input-element on the wrong wrapper
1608        // .jetpack-field__input-element is meant to be applied just to the input element, not its wrapper.
1609        // Remove the jetpack-field__input-element class token regardless of its position and normalize whitespace.
1610        $class_names = preg_replace( '/\s*\bjetpack-field__input-element\b\s*/', ' ', $class_names );
1611        $class_names = trim( preg_replace( '/\s+/', ' ', $class_names ) );
1612
1613        $link_label_id = $id . '-number';
1614
1615        $this->set_invalid_message( 'phone', __( 'Please enter a valid phone number', 'jetpack-forms' ) );
1616        $label = $this->render_label( 'phone', $link_label_id, $label, $required, $required_field_text, array(), false, $required_indicator );
1617        if ( ! is_string( $value ) ) {
1618            $value = '';
1619        }
1620
1621        $translated_countries = $this->get_translatable_countries();
1622        $global_config        = array(
1623            'i18n' => array(
1624                'countryNames' => $translated_countries,
1625            ),
1626        );
1627        wp_interactivity_config( 'jetpack/field-phone', $global_config );
1628        ob_start();
1629        ?>
1630        <div
1631            class="jetpack-field__input-phone-wrapper <?php echo esc_attr( $this->get_attribute( 'stylevariationclasses' ) ); ?> <?php echo esc_attr( $class_names ); ?>"
1632            style="<?php echo ( ! empty( $this->field_styles ) && is_string( $this->field_styles ) ? esc_attr( $this->field_styles ) : '' ); ?>"
1633            data-wp-on--jetpack-form-reset='actions.phoneResetHandler'
1634            data-wp-class--is-combobox-open="context.comboboxOpen"
1635            <?php
1636            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- function is supposed to work this way
1637            echo wp_interactivity_data_wp_context(
1638                array(
1639                    'fieldId'             => $id,
1640                    'defaultCountry'      => $default_country,
1641                    'showCountrySelector' => $this->get_attribute( 'showcountryselector' ),
1642                    // dynamic
1643                    'phoneNumber'         => '',
1644                    'phoneCountryCode'    => $default_country,
1645                    'fullPhoneNumber'     => '',
1646                    'countryPrefix'       => '',
1647                    // combobox state
1648                    'useCombobox'         => true,
1649                    'comboboxOpen'        => false,
1650                    'searchTerm'          => '',
1651                    'allCountries'        => array(),
1652                    'filteredCountries'   => array(),
1653                    'selectedCountry'     => array(),
1654                )
1655            );
1656            ?>
1657            >
1658                <div class="jetpack-field__input-prefix"
1659                    data-wp-bind--hidden="!context.showCountrySelector"
1660                    data-wp-on-document--click="actions.phoneComboboxDocumentClickHandler">
1661                    <div class="jetpack-custom-combobox">
1662
1663                        <button
1664                            class="jetpack-combobox-trigger"
1665                            type="button"
1666                            data-wp-on--click="actions.phoneComboboxToggle"
1667                            data-wp-bind--aria-expanded="context.comboboxOpen">
1668                            <span
1669                                class="jetpack-combobox-selected wp-exclude-emoji"
1670                                data-wp-watch="callbacks.updateSelectedFlag">&#8203;</span>
1671                            <span
1672                                class="jetpack-combobox-trigger-arrow"
1673                                data-wp-class--is-open="context.comboboxOpen">
1674                                <svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
1675                                    <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1676                                </svg>
1677                            </span>
1678                            <span
1679                                class="jetpack-combobox-selected"
1680                                data-wp-text="context.selectedCountry.value"></span>
1681                        </button>
1682                        <div
1683                            class="jetpack-combobox-dropdown <?php echo esc_attr( $this->get_attribute( 'stylevariationclasses' ) ); ?>"
1684                            style="<?php echo ( ! empty( $this->field_styles ) && is_string( $this->field_styles ) ? esc_attr( $this->field_styles ) : '' ); ?>"
1685                            data-wp-bind--hidden="!context.comboboxOpen">
1686                            <input
1687                                class="jetpack-combobox-search"
1688                                type="text"
1689                                placeholder="<?php echo esc_attr( $search_placeholder ); ?>"
1690                                data-wp-on--input="actions.phoneComboboxInputHandler"
1691                                data-wp-on--keydown="actions.phoneComboboxKeydownHandler"
1692                                data-wp-init="callbacks.registerPhoneComboboxSearchInput">
1693                            <div class="jetpack-combobox-options" data-wp-init="callbacks.registerPhoneComboboxOptionsList">
1694                                <template
1695                                    data-wp-each--filtered="context.filteredCountries"
1696                                    data-wp-each-key="context.filtered.code">
1697                                    <div
1698                                        class="jetpack-combobox-option"
1699                                        data-wp-key="context.filtered.code"
1700                                        data-wp-class--jetpack-combobox-option-selected="context.filtered.selected"
1701                                        data-wp-on--click="actions.phoneCountryChangeHandler">
1702                                        <span class="jetpack-combobox-option-icon wp-exclude-emoji" data-wp-watch="callbacks.updateOptionFlag">&#8203;</span>
1703                                        <span class="jetpack-combobox-option-value" data-wp-text="context.filtered.value"></span>
1704                                        <span class="jetpack-combobox-option-description" data-wp-text="context.filtered.country"></span>
1705                                    </div>
1706                                </template>
1707                            </div>
1708                        </div>
1709                    </div>
1710                </div>
1711                <input
1712                    class="jetpack-field__input-element"
1713                    <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- both are escaped in calling function ?>
1714                    <?php echo $placeholder; ?>
1715                    type="tel"
1716                    <?php if ( $required ) { ?>
1717                        required="true"
1718                        aria-required="true"
1719                    <?php } ?>
1720                    id="<?php echo esc_attr( $link_label_id ); ?>"
1721                    name="<?php echo esc_attr( $link_label_id ); ?>"
1722                    data-wp-bind--disabled='state.isSubmitting'
1723                    data-wp-bind--aria-invalid='state.fieldAriaInvalid'
1724                    data-wp-bind--value='context.phoneNumber'
1725                    aria-describedby="<?php echo esc_attr( $this->get_described_by( $id, 'telephone' ) ); ?>"
1726                    data-wp-on--input='actions.phoneNumberInputHandler'
1727                    data-wp-on--blur='actions.onFieldBlur'
1728                    data-wp-on--focus='actions.phoneNumberFocusHandler'
1729                    data-wp-class--has-value='context.phoneNumber'
1730                    data-wp-init="callbacks.registerPhoneInput"
1731                    data-wp-init--phone-field-custom-combobox="callbacks.initializePhoneFieldCustomComboBox"
1732                    <?php echo $this->get_hidden_label_aria_label_attr(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attribute value is escaped in the helper. ?>
1733                    />
1734                <input type="hidden"
1735                    id="<?php echo esc_attr( $id ); ?>"
1736                    name="<?php echo esc_attr( $id ); ?>"
1737                    data-wp-bind--value='context.fullPhoneNumber' />
1738        </div>
1739        <?php
1740        $input = ob_get_clean();
1741
1742        $field = $label . $input . $this->get_field_descriptions( $id, 'telephone' );
1743        return $field;
1744    }
1745
1746    /**
1747     * Return the HTML for the URL field.
1748     *
1749     * @param int    $id - the ID.
1750     * @param string $label - the label.
1751     * @param string $value - the value of the field.
1752     * @param string $class - the field class.
1753     * @param bool   $required - if the field is marked as required.
1754     * @param string $required_field_text - the text in the required text field.
1755     * @param string $placeholder - the field placeholder content.
1756     * @param bool   $required_indicator Whether to display the required indicator.
1757     *
1758     * @return string HTML
1759     */
1760    public function render_url_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $required_indicator = true ) {
1761        $this->set_invalid_message( 'url', __( 'Please enter a valid URL - https://www.example.com', 'jetpack-forms' ) );
1762
1763        $field  = $this->render_label( 'url', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
1764        $field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
1765        return $field;
1766    }
1767
1768    /**
1769     * Return the HTML for the text area field.
1770     *
1771     * @param int    $id - the ID.
1772     * @param string $label - the label.
1773     * @param string $value - the value of the field.
1774     * @param string $class - the field class.
1775     * @param bool   $required - if the field is marked as required.
1776     * @param string $required_field_text - the text in the required text field.
1777     * @param string $placeholder - the field placeholder content.
1778     * @param bool   $required_indicator Whether to display the required indicator.
1779     *
1780     * @return string HTML
1781     */
1782    public function render_textarea_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $required_indicator = true ) {
1783        if ( ! is_string( $value ) ) {
1784            $value = '';
1785        }
1786
1787        $field  = $this->render_label( 'textarea', 'contact-form-comment-' . $id, $label, $required, $required_field_text, array(), false, $required_indicator );
1788        $field .= "<textarea
1789                        style='" . $this->field_styles . "'
1790                        name='" . esc_attr( $id ) . "'
1791                        id='contact-form-comment-" . esc_attr( $id ) . "'
1792                        rows='20'
1793                        data-wp-text='state.getFieldValue'
1794                        data-wp-on--input='actions.onFieldChange'
1795                        data-wp-on--blur='actions.onFieldBlur'
1796                        data-wp-class--has-value='state.hasFieldValue'
1797                        aria-describedby='" . esc_attr( $this->get_described_by( $id, 'textarea' ) ) . "'
1798                        data-wp-bind--aria-invalid='state.fieldAriaInvalid'
1799                        "
1800                        . $this->get_hidden_label_aria_label_attr()
1801                        . $class
1802                        . $placeholder
1803                        . ' ' . ( $required ? "required aria-required='true'" : '' ) .
1804                        '>' . esc_textarea( $value )
1805                . "</textarea>\n " . $this->get_field_descriptions( $id, 'textarea' ) . "\n";
1806        return $field;
1807    }
1808
1809    /**
1810     * Return the HTML for the radio field.
1811     *
1812     * @param string $id - the ID (starts with 'g' - see constructor).
1813     * @param string $label - the label.
1814     * @param string $value - the value of the field.
1815     * @param string $class - the field class.
1816     * @param bool   $required - if the field is marked as required.
1817     * @param string $required_field_text - the text in the required text field.
1818     * @param bool   $required_indicator Whether to display the required indicator.
1819     *
1820     * @return string HTML
1821     */
1822    public function render_radio_field( $id, $label, $value, $class, $required, $required_field_text, $required_indicator = true ) {
1823        $this->set_invalid_message( 'radio', __( 'Please select one of the options.', 'jetpack-forms' ) );
1824        $options_classes   = $this->get_attribute( 'optionsclasses' );
1825        $options_styles    = $this->get_attribute( 'optionsstyles' );
1826        $form_style        = $this->get_form_style();
1827        $is_outlined_style = 'outlined' === $form_style;
1828        $fieldset_id       = "id='" . esc_attr( "$id-label" ) . "'" . $this->get_hidden_label_aria_label_attr();
1829
1830        if ( $is_outlined_style ) {
1831            $style_variation_attributes = $this->get_attribute( 'stylevariationattributes' );
1832
1833            if ( ! empty( $style_variation_attributes ) ) {
1834                $style_variation_attributes = json_decode( html_entity_decode( $style_variation_attributes, ENT_COMPAT ), true );
1835            }
1836
1837            // When there's an outlined style, and border radius is set, the existing inline border radius is overridden to apply
1838            // a limit of `100px` to the radius on the x axis. This achieves the same look and feel as other fields
1839            // that use the notch html (`notched-label__leading` has a max-width of `100px` to prevent it from getting too wide).
1840            // It prevents large border radius values from disrupting the look and feel of the fields.
1841            if ( isset( $style_variation_attributes['border']['radius'] ) ) {
1842                $options_styles        ??= '';
1843                $radius                  = $style_variation_attributes['border']['radius'];
1844                $has_split_radius_values = is_array( $radius );
1845                $top_left_radius         = $has_split_radius_values ? $radius['topLeft'] : $radius;
1846                $top_right_radius        = $has_split_radius_values ? $radius['topRight'] : $radius;
1847                $bottom_left_radius      = $has_split_radius_values ? $radius['bottomLeft'] : $radius;
1848                $bottom_right_radius     = $has_split_radius_values ? $radius['bottomRight'] : $radius;
1849                $options_styles         .= "border-top-left-radius: min(100px, {$top_left_radius}) {$top_left_radius};";
1850                $options_styles         .= "border-top-right-radius: min(100px, {$top_right_radius}) {$top_right_radius};";
1851                $options_styles         .= "border-bottom-left-radius: min(100px, {$bottom_left_radius}) {$bottom_left_radius};";
1852                $options_styles         .= "border-bottom-right-radius: min(100px, {$bottom_right_radius}) {$bottom_right_radius};";
1853            }
1854
1855            /*
1856             * For the "outlined" style, the styles and classes are applied to the fieldset element.
1857             */
1858            $field = "<fieldset {$fieldset_id} class='grunion-radio-options " . esc_attr( $options_classes ) . "' style='" . esc_attr( $options_styles ) . "' data-wp-bind--aria-invalid='state.fieldAriaInvalid' >";
1859        } else {
1860            $field = "<fieldset {$fieldset_id} class='jetpack-field-multiple__fieldset' data-wp-bind--aria-invalid='state.fieldAriaInvalid' >";
1861        }
1862
1863        $field .= $this->render_legend_as_label( '', $id, $label, $required, $required_field_text, array(), $required_indicator );
1864
1865        if ( ! $is_outlined_style ) {
1866            $field .= "<div class='grunion-radio-options " . esc_attr( $options_classes ) . "' style='" . esc_attr( $options_styles ) . "'>";
1867        }
1868
1869        $options_data  = $this->get_attribute( 'optionsdata' );
1870        $used_html_ids = array();
1871
1872        if ( ! empty( $options_data ) ) {
1873            foreach ( $options_data as $option_index => $option ) {
1874                $option_label = Contact_Form_Plugin::strip_tags( $option['label'] );
1875                if ( is_string( $option_label ) && '' !== $option_label ) {
1876                    $radio_value = $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option_label );
1877                    $radio_id    = $id . '-' . sanitize_html_class( $radio_value );
1878
1879                    // If exact id was already used in this radio group, append option index.
1880                    // Multiple 'blue' options would give id-blue, id-blue-1, id-blue-2, etc.
1881                    if ( isset( $used_html_ids[ $radio_id ] ) ) {
1882                        $radio_id .= '-' . $option_index;
1883                    }
1884                    $used_html_ids[ $radio_id ] = true;
1885
1886                    $default_classes = 'contact-form-field';
1887                    $option_styles   = empty( $option['style'] ) ? '' : "style='" . esc_attr( $option['style'] ) . "'";
1888                    $option_classes  = empty( $option['class'] ) ? $default_classes : $default_classes . ' ' . esc_attr( $option['class'] );
1889
1890                    $is_other_option  = ! empty( $option['isOther'] );
1891                    $change_action    = $is_other_option ? 'actions.onOtherRadioChange' : 'actions.onFieldChange';
1892                    $other_label_attr = $is_other_option ? " data-other-label='" . esc_attr( $option_label ) . "'" : '';
1893
1894                    $field .= "<label {$option_styles} class='{$option_classes}'>";
1895                    $field .= "<input
1896                                    id='" . esc_attr( $radio_id ) . "'
1897                                    type='radio'
1898                                    name='" . esc_attr( $id ) . "'
1899                                    value='" . esc_attr( $radio_value ) . "'
1900                                    data-wp-on--change='" . $change_action . "'"
1901                                    . $other_label_attr . ' '
1902                                    . $class
1903                                    . checked( $option_label, $value, false ) . ' '
1904                                    . ( $required ? "required aria-required='true'" : '' )
1905                                    . '/> ';
1906                    $field .= "<span class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
1907                    $field .= "<span class='grunion-field-text'>" . esc_html( $option_label ) . '</span>';
1908                    $field .= '</span>';
1909                    $field .= '</label>';
1910
1911                    if ( $is_other_option ) {
1912                        $placeholder = ! empty( $option['otherPlaceholder'] ) ? $option['otherPlaceholder'] : '';
1913                        $field      .= $this->render_other_input_field( $radio_id, $required, $id, $this->field_styles, $placeholder );
1914                    }
1915                }
1916            }
1917        } else {
1918            $field_style = 'style="' . $this->option_styles . '"';
1919
1920            foreach ( (array) $this->get_attribute( 'options' ) as $option_index => $option ) {
1921                $option = Contact_Form_Plugin::strip_tags( $option );
1922                if ( is_string( $option ) && '' !== $option ) {
1923                    $radio_value = $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option );
1924                    $radio_id    = $id . '-' . sanitize_html_class( $radio_value );
1925
1926                    // If exact id was already used in this radio group, append option index.
1927                    // Multiple 'blue' options would give id-blue, id-blue-1, id-blue-2, etc.
1928                    if ( isset( $used_html_ids[ $radio_id ] ) ) {
1929                        $radio_id .= '-' . $option_index;
1930                    }
1931                    $used_html_ids[ $radio_id ] = true;
1932
1933                    $field .= "<p class='contact-form-field'>";
1934                    $field .= "<input
1935                                    id='" . esc_attr( $radio_id ) . "'
1936                                    type='radio'
1937                                    name='" . esc_attr( $id ) . "'
1938                                    value='" . esc_attr( $radio_value ) . "'
1939                                    data-wp-on--change='actions.onFieldChange' "
1940                                    . $class
1941                                    . checked( $option, $value, false ) . ' '
1942                                    . ( $required ? "required aria-required='true'" : '' )
1943                                    . '/> ';
1944                    $field .= "<label for='" . esc_attr( $radio_id ) . "' {$field_style} class='grunion-radio-label radio" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
1945                    $field .= "<span class='grunion-field-text'>" . esc_html( $option ) . '</span>';
1946                    $field .= '</label>';
1947                    $field .= '</p>';
1948                }
1949            }
1950        }
1951
1952        if ( ! $is_outlined_style ) {
1953            $field .= '</div>';
1954        }
1955        $field .= $this->get_error_div( $id, 'radio' ) . '</fieldset>';
1956        return $field;
1957    }
1958
1959    /**
1960     * Render the "Other" text input field for radio and checkbox fields.
1961     *
1962     * @param string $option_id The ID of the "Other" option.
1963     * @param bool   $required Whether the main field is required.
1964     * @param string $id The base ID of the main field.
1965     * @param string $field_styles The styles to apply to the text input field.
1966     * @param string $placeholder Placeholder text for the input field.
1967     *
1968     * @return string The HTML for the "Other" text input field.
1969     */
1970    private function render_other_input_field( $option_id, $required, $id, $field_styles, $placeholder ) {
1971        $other_text_id      = esc_attr( $option_id ) . '-other-text';
1972        $other_label_id     = esc_attr( $option_id ) . '-other-label';
1973        $other_label_text   = ! empty( $placeholder ) ? $placeholder : __( 'Please specify…', 'jetpack-forms' );
1974        $aria_required_attr = $required ? "aria-required='true'" : '';
1975        $other_input_styles = ! empty( $field_styles ) ? " style='" . esc_attr( $field_styles ) . "' " : '';
1976
1977        $field  = "<div class='jetpack-other-text-input-wrapper' data-wp-class--is-visible='state.isOtherSelected' role='region'>";
1978        $field .= "<label id='" . $other_label_id . "' for='" . $other_text_id . "' class='screen-reader-text'>" . esc_html( $other_label_text ) . '</label>';
1979        $field .= "<input
1980                    id='" . $other_text_id . "'
1981                    name='" . esc_attr( $id ) . "-other-text'
1982                    type='text'
1983                    class='grunion-field'
1984                    " . $other_input_styles . "
1985                    placeholder='" . esc_attr( $placeholder ) . "'
1986                    value=''
1987                    aria-labelledby='" . $other_label_id . "'
1988                    " . $aria_required_attr . "
1989                    data-wp-on--input='actions.onOtherTextInput'
1990                    data-wp-bind--disabled='!state.isOtherSelected'
1991                    data-wp-class--has-value='state.hasFieldValue' />";
1992        $field .= '</div>';
1993
1994        return $field;
1995    }
1996
1997    /**
1998     * Return the HTML for the checkbox field.
1999     *
2000     * @param int    $id - the ID.
2001     * @param string $label - the label.
2002     * @param string $value - the value of the field.
2003     * @param string $class - the field class.
2004     * @param bool   $required - if the field is marked as required.
2005     * @param string $required_field_text - the text in the required text field.
2006     * @param bool   $required_indicator Whether to display the required indicator.
2007     *
2008     * @return string HTML
2009     */
2010    public function render_checkbox_field( $id, $label, $value, $class, $required, $required_field_text, $required_indicator = true ) {
2011        $label_class                   = 'grunion-field-label checkbox';
2012        $label_class                  .= $this->is_error() ? ' form-error' : '';
2013        $label_class                  .= $this->label_classes ? ' ' . $this->label_classes : '';
2014        $label_class                  .= $this->option_classes ? ' ' . $this->option_classes : '';
2015        $has_inner_block_option_styles = ! empty( $this->get_attribute( 'optionstyles' ) );
2016
2017        $field  = "<div class='contact-form__checkbox-wrap' style='" . ( $has_inner_block_option_styles ? esc_attr( $this->option_styles ) : '' ) . "' >";
2018        $field .= "<input id='" . esc_attr( $id ) . "' type='checkbox' data-wp-on--change='actions.onFieldChange' name='" . esc_attr( $id ) . "' value='" . esc_attr__( 'Yes', 'jetpack-forms' ) . "' " . $class . checked( (bool) $value, true, false ) . ' ' . ( $required ? "required aria-required='true'" : '' ) . "/> \n";
2019        $field .= "<label for='" . esc_attr( $id ) . "' class='" . esc_attr( $label_class ) . "' style='" . esc_attr( $this->label_styles ) . ( $has_inner_block_option_styles ? esc_attr( $this->option_styles ) : '' ) . "'>";
2020        $field .= wp_kses_post( $label ) . ( $required && $required_indicator ? '<span class="grunion-label-required" aria-hidden="true">' . $required_field_text . '</span>' : '' );
2021        $field .= "</label>\n";
2022        $field .= "<div class='clear-form'></div>\n";
2023        $field .= '</div>';
2024        return $field . $this->get_error_div( $id, 'checkbox' );
2025    }
2026
2027    /**
2028     * Return the HTML for the consent field.
2029     *
2030     * @param string $id field id.
2031     * @param string $class html classes (can be set by the admin).
2032     */
2033    private function render_consent_field( $id, $class ) {
2034        $consent_type                  = 'explicit' === $this->get_attribute( 'consenttype' ) ? 'explicit' : 'implicit';
2035        $consent_message               = 'explicit' === $consent_type ? $this->get_attribute( 'explicitconsentmessage' ) : $this->get_attribute( 'implicitconsentmessage' );
2036        $label_class                   = 'grunion-field-label consent consent-' . esc_attr( $consent_type );
2037        $label_class                  .= $this->option_classes ? ' ' . $this->option_classes : '';
2038        $label_class                  .= $this->is_error() ? ' form-error' : '';
2039        $has_inner_block_option_styles = ! empty( $this->get_attribute( 'optionstyles' ) );
2040
2041        $field = "<label class='" . esc_attr( $label_class ) . "' style='" . esc_attr( $this->label_styles ) . ( $has_inner_block_option_styles ? esc_attr( $this->option_styles ) : '' ) . "'>";
2042
2043        if ( 'implicit' === $consent_type ) {
2044            $field .= "\t\t<input type='hidden' name='" . esc_attr( $id ) . "' value='" . esc_attr__( 'Yes', 'jetpack-forms' ) . "' /> \n";
2045        } else {
2046            $field .= "\t\t<input type='checkbox' data-wp-on--change='actions.onFieldChange' name='" . esc_attr( $id ) . "' value='" . esc_attr__( 'Yes', 'jetpack-forms' ) . "' " . $class . "/> \n";
2047        }
2048        $field .= "\t\t" . wp_kses_post( $consent_message );
2049        $field .= "</label>\n";
2050        $field .= "<div class='clear-form'></div>\n";
2051        return $field . $this->get_error_div( $id, 'checkbox' );
2052    }
2053
2054    /**
2055     * Return the HTML for the file field.
2056     *
2057     * Renders a file upload field with drag-and-drop functionality.
2058     *
2059     * @since 0.45.0
2060     *
2061     * @param string $id - the field ID.
2062     * @param string $label - the field label.
2063     * @param string $class - unused. Kept for signature parity with the other render_*_field()
2064     *                        methods; this field writes its input inline rather than from an
2065     *                        attribute array.
2066     * @param bool   $required - if the field is marked as required.
2067     * @param string $required_field_text - the text in the required text field.
2068     * @param bool   $required_indicator Whether to display the required indicator.
2069     *
2070     * @return string HTML for the file upload field.
2071     */
2072    private function render_file_field( $id, $label, $class, $required, $required_field_text, $required_indicator = true ) {
2073        // Check if Jetpack is active
2074        if ( ! defined( 'JETPACK__PLUGIN_DIR' ) ) {
2075            return '<div class="jetpack-form-field-error">' .
2076                esc_html__( 'File upload field requires Jetpack to be active.', 'jetpack-forms' ) .
2077                '</div>';
2078        }
2079
2080        $this->set_invalid_message( 'file_uploading', __( 'Please wait a moment, file is currently uploading.', 'jetpack-forms' ) );
2081        $this->set_invalid_message( 'file_has_errors', __( 'Please remove any file upload errors.', 'jetpack-forms' ) );
2082
2083        // Enqueue necessary scripts and styles.
2084        $this->enqueue_file_field_assets();
2085
2086        $accept_attribute_value = implode( ', ', self::get_file_field_accepted_mime_types() );
2087        $max_files              = $this->get_file_field_max_files();
2088        $max_upload_size        = self::get_file_field_max_upload_size();
2089
2090        $file_size_units = array(
2091            _x( 'B', 'unit symbol', 'jetpack-forms' ),
2092            _x( 'KB', 'unit symbol', 'jetpack-forms' ),
2093            _x( 'MB', 'unit symbol', 'jetpack-forms' ),
2094            _x( 'GB', 'unit symbol', 'jetpack-forms' ),
2095        );
2096
2097        $global_config = array(
2098            'i18n'          => array(
2099                'language'           => get_bloginfo( 'language' ),
2100                'fileSizeUnits'      => $file_size_units,
2101                'zeroBytes'          => __( '0 Bytes', 'jetpack-forms' ),
2102                'uploadError'        => __( 'Error uploading file', 'jetpack-forms' ),
2103                'folderNotSupported' => __( 'Folder uploads are not supported', 'jetpack-forms' ),
2104                // translators: %s is the formatted maximum file size.
2105                'fileTooLarge'       => sprintf( __( 'File is too large. Maximum allowed size is %s.', 'jetpack-forms' ), size_format( $max_upload_size ) ),
2106                'invalidType'        => __( 'This file type is not allowed.', 'jetpack-forms' ),
2107                'maxFiles'           => __( 'You have exceeded the number of files that you can upload.', 'jetpack-forms' ),
2108                'uploadFailed'       => __( 'File upload failed, try again.', 'jetpack-forms' ),
2109            ),
2110            'endpoint'      => $this->get_unauth_endpoint_url(),
2111            'iconsPath'     => Jetpack_Forms::plugin_url() . 'contact-form/images/file-icons/',
2112            'maxUploadSize' => $max_upload_size,
2113        );
2114
2115        wp_interactivity_config( 'jetpack/field-file', $global_config );
2116
2117        // Only genuinely dynamic state lives in the context. The field's static configuration
2118        // (`maxFiles`, `allowedMimeTypes`) travels in `fieldExtra` alongside every other field's
2119        // config, and `fieldId` already comes from the wrapper that `render_field()` opens.
2120        $context = array(
2121            'isDropping' => false,
2122            'files'      => array(),
2123            // Field-level message that belongs to no single file â€” currently only the result of
2124            // offering more files than the field accepts. Per-file problems stay on their preview.
2125            'fileNotice' => '',
2126        );
2127
2128        $field = $this->render_label( 'file', $id, $label, $required, $required_field_text, array(), true, $required_indicator );
2129
2130        $dropzone_aria_label = $this->get_file_dropzone_aria_label( $max_files );
2131
2132        ob_start();
2133        ?>
2134        <div
2135            class="jetpack-form-file-field__container"
2136            id="<?php echo esc_attr( $id ); ?>"
2137            name="dropzone-<?php echo esc_attr( $id ); ?>"
2138            <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output is pre-escaped by method ?>
2139            <?php echo wp_interactivity_data_wp_context( $context ); ?>
2140            data-wp-on--dragover="actions.onFileDragOver"
2141            data-wp-on--dragleave="actions.onFileDragLeave"
2142            data-wp-on--mouseleave="actions.onFileDragLeave"
2143            data-wp-on--drop="actions.fileDropped"
2144            data-wp-on--jetpack-form-reset="actions.resetFiles"
2145            data-is-required="<?php echo esc_attr( $required ); ?>"
2146        >
2147            <div class="jetpack-form-file-field__dropzone" data-wp-class--is-dropping="context.isDropping" data-wp-class--is-hidden="state.isFileFieldFull">
2148                <div class="jetpack-form-file-field__dropzone-inner" data-wp-on--click="actions.openFilePicker" data-wp-on--keydown="actions.onFileDropzoneKeyDown" tabindex="0" role="button" aria-label="<?php echo esc_attr( $dropzone_aria_label ); ?>"></div>
2149                <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Decoded dropzone markup is filtered through an allowlist by sanitize_file_field_content(). ?>
2150                <?php echo $this->sanitize_file_field_content( $this->content ); ?>
2151                <input
2152                    type="file" class="jetpack-form-file-field"
2153                    accept="<?php echo esc_attr( $accept_attribute_value ); ?>"
2154                    <?php // Without this the picker returns one file at a time however many the field accepts; a drop already could. ?>
2155                    <?php echo $max_files > 1 ? 'multiple' : ''; ?>
2156                    data-wp-on--change="actions.fileAdded"  />
2157            </div>
2158            <div class="jetpack-form-file-field__preview-wrap" name="file-field-<?php echo esc_attr( $id ); ?>" data-wp-class--is-active="state.hasFileFieldFiles">
2159                <template data-wp-each--file="context.files" data-wp-key="context.file.id">
2160                    <div class="jetpack-form-file-field__preview" tabindex="0" data-wp-bind--aria-label="context.file.name" data-wp-init--focus="callbacks.focusFilePreview" data-wp-class--is-error="context.file.hasError" data-wp-class--is-complete="context.file.isUploaded">
2161                        <input type="hidden" name="<?php echo esc_attr( $id ); ?>[]" class="jetpack-form-file-field__hidden include-hidden" data-wp-bind--value='context.file.fileJson' value="">
2162                        <div class="jetpack-form-file-field__image-wrap" data-wp-style----progress="context.file.progress" data-wp-class--has-icon="context.file.hasIcon">
2163                            <div class="jetpack-form-file-field__image" data-wp-style--background-image="context.file.url" data-wp-style--mask-image="context.file.mask"></div>
2164                            <div class="jetpack-form-file-field__progress-bar" ></div>
2165                        </div>
2166
2167                        <div class="jetpack-form-file-field__file-wrap">
2168                            <strong class="jetpack-form-file-field__file-name" data-wp-text="context.file.name"></strong>
2169                            <div class="jetpack-form-file-field__file-info" data-wp-class--is-error="context.file.error" data-wp-class--is-complete="context.file.file_id">
2170                                <span class="jetpack-form-file-field__file-size" data-wp-text="context.file.formattedSize"></span>
2171                                <span class="jetpack-form-file-field__seperator"> &middot; </span>
2172                                <span aria-live="polite">
2173                                    <span class="jetpack-form-file-field__uploading"><?php esc_html_e( 'Uploading…', 'jetpack-forms' ); ?></span>
2174                                    <span class="jetpack-form-file-field__success"><?php esc_html_e( 'Uploaded', 'jetpack-forms' ); ?></span>
2175                                    <span class="jetpack-form-file-field__error" data-wp-text="context.file.error"></span>
2176                                </span>
2177                            </div>
2178                        </div>
2179                        <a href="#" class="jetpack-form-file-field__remove" data-wp-bind--data-id='context.file.id' aria-label="<?php esc_attr_e( 'Remove file', 'jetpack-forms' ); ?>" data-wp-on--click="actions.removeFile" data-wp-on--keydown="actions.removeFileKeydown" title="<?php esc_attr_e( 'Remove', 'jetpack-forms' ); ?>"> </a>
2180                    </div>
2181                </template>
2182            </div>
2183            <p class="jetpack-form-file-field__notice" role="status" data-wp-class--is-visible="state.hasFileFieldNotice" data-wp-text="context.fileNotice"></p>
2184        </div>
2185        <?php
2186        return $field . ob_get_clean() . $this->get_error_div( $id, 'file' );
2187    }
2188
2189    /**
2190     * Sanitize the file field's inner (dropzone) content for output.
2191     *
2192     * The dropzone's inner blocks are serialized into the [contact-field] shortcode with
2193     * esc_html() (see Contact_Form::parse_contact_field()), so the stored content has to be
2194     * entity-decoded before it can render. Decoding is what makes this dangerous: an author
2195     * who cannot post raw HTML can still store entity-encoded markup in the shortcode's inner
2196     * content, which post-save KSES treats as plain text and never inspects. Decoding it would
2197     * turn that text back into live markup, so the decoded result is filtered here instead of
2198     * being echoed as-is.
2199     *
2200     * @since 7.24.0
2201     *
2202     * @param string|null $content The raw (entity-encoded) field content.
2203     *
2204     * @return string Safe HTML.
2205     */
2206    private function sanitize_file_field_content( $content ) {
2207        if ( ! is_string( $content ) || $content === '' ) {
2208            return '';
2209        }
2210
2211        // core/icon serializes its rotation as `rotate: 45deg`, which is not in WordPress's
2212        // safe_style_css list, so safecss_filter_attr() would strip it and render the icon
2213        // unrotated. Allow it just for the duration of this call.
2214        $allow_rotate = static function ( $attrs ) {
2215            $attrs[] = 'rotate';
2216            return $attrs;
2217        };
2218
2219        add_filter( 'safe_style_css', $allow_rotate );
2220
2221        try {
2222            return wp_kses(
2223                html_entity_decode( $content, ENT_COMPAT, 'UTF-8' ),
2224                self::get_file_field_allowed_html()
2225            );
2226        } finally {
2227            remove_filter( 'safe_style_css', $allow_rotate );
2228        }
2229    }
2230
2231    /**
2232     * Build the allowlist of HTML permitted inside the file field's dropzone.
2233     *
2234     * Starts from the standard post allowlist and adds the things the dropzone's allowed inner
2235     * blocks legitimately rely on that wp_kses_post() would otherwise drop:
2236     *
2237     * - `tabindex`, which Contact_Form_Plugin::gutenblock_render_dropzone() sets to "-1" on inner
2238     *   links and buttons so the dropzone stays a single tab stop.
2239     * - `srcset`, `sizes` and `decoding` on images, so core/image keeps serving responsive
2240     *   sources instead of silently degrading to the full-size file.
2241     * - A conservative inline SVG subset, since core/icon is an allowed inner block of the
2242     *   dropzone. Only presentational elements and attributes are listed; because wp_kses() is
2243     *   allowlist-based, event handlers and scripting constructs are dropped automatically.
2244     *
2245     * Note that wp_kses_allowed_html( 'post' ) permits `data-*` globally, so Interactivity API
2246     * directives survive this filter. Their values are store paths rather than executable code,
2247     * and removing them would also break core/image's lightbox markup, so they are left alone.
2248     *
2249     * @since 7.24.0
2250     *
2251     * @return array Allowed HTML, in wp_kses() format.
2252     */
2253    private static function get_file_field_allowed_html() {
2254        $allowed = wp_kses_allowed_html( 'post' );
2255
2256        foreach ( array( 'a', 'button' ) as $tag ) {
2257            if ( isset( $allowed[ $tag ] ) && is_array( $allowed[ $tag ] ) ) {
2258                $allowed[ $tag ]['tabindex'] = true;
2259            }
2260        }
2261
2262        // wp_kses() only runs its protocol check on a fixed set of URL attributes, which does not
2263        // include `srcset`. That is not a script vector - srcset candidates are fetched as images,
2264        // so a `javascript:` candidate never executes - and `src` is still protocol-checked.
2265        if ( isset( $allowed['img'] ) && is_array( $allowed['img'] ) ) {
2266            $allowed['img'] += array(
2267                'srcset'   => true,
2268                'sizes'    => true,
2269                'decoding' => true,
2270            );
2271        }
2272
2273        // `style` is deliberately absent here: safecss_filter_attr() strips the SVG presentation
2274        // properties (fill, stroke) that would justify it, while still permitting things like
2275        // position:fixed. It is allowed on the root <svg> below, where block supports need it.
2276        $svg_common = array(
2277            'class'           => true,
2278            'fill'            => true,
2279            'fill-rule'       => true,
2280            'clip-rule'       => true,
2281            'stroke'          => true,
2282            'stroke-width'    => true,
2283            'stroke-linecap'  => true,
2284            'stroke-linejoin' => true,
2285            'opacity'         => true,
2286            'transform'       => true,
2287        );
2288
2289        $allowed['svg'] = array_merge(
2290            $svg_common,
2291            array(
2292                'style'       => true,
2293                'xmlns'       => true,
2294                'viewbox'     => true,
2295                'width'       => true,
2296                'height'      => true,
2297                'role'        => true,
2298                'focusable'   => true,
2299                'aria-hidden' => true,
2300                'aria-label'  => true,
2301            )
2302        );
2303
2304        // Child elements: the shared presentation attributes plus each element's own geometry.
2305        $svg_children = array(
2306            'g'        => array(),
2307            'path'     => array( 'd' ),
2308            'circle'   => array( 'cx', 'cy', 'r' ),
2309            'ellipse'  => array( 'cx', 'cy', 'rx', 'ry' ),
2310            'line'     => array( 'x1', 'y1', 'x2', 'y2' ),
2311            'polygon'  => array( 'points' ),
2312            'polyline' => array( 'points' ),
2313            'rect'     => array( 'x', 'y', 'width', 'height', 'rx', 'ry' ),
2314        );
2315
2316        foreach ( $svg_children as $tag => $attrs ) {
2317            $allowed[ $tag ] = $svg_common + array_fill_keys( $attrs, true );
2318        }
2319
2320        return $allowed;
2321    }
2322
2323    /**
2324     * Render a hidden field.
2325     *
2326     * @param string $id - the field ID.
2327     * @param string $label - the field label.
2328     * @param string $value - the value of the field.
2329     *
2330     * @return string HTML for the hidden field.
2331     */
2332    private function render_hidden_field( $id, $label, $value ) {
2333        if ( ! $this->is_submitted_hidden_field_value( $id ) ) {
2334            $value = $this->get_filtered_hidden_field_value( $value, $label, $id );
2335        }
2336
2337        $context = array(
2338            'fieldId'         => $id,
2339            'fieldType'       => 'hidden',
2340            'fieldLabel'      => $label,
2341            'fieldValue'      => $value,
2342            'fieldIsRequired' => false,
2343            'fieldExtra'      => array(),
2344            'formHash'        => $this->form ? $this->form->hash : '',
2345        );
2346
2347        $interactivity_attributes = "data-jp-field-id='" . esc_attr( $id ) . "' data-wp-interactive='jetpack/form' "
2348            . wp_interactivity_data_wp_context( $context )
2349            . " data-wp-init='callbacks.initializeField' data-wp-on--jetpack-form-reset='callbacks.initializeField'";
2350
2351        return "<input type='hidden' name='" . esc_attr( $id ) . "' id='" . esc_attr( $id )
2352            . "' value='" . esc_attr( $value ) . "' " . $interactivity_attributes . " />\n";
2353    }
2354
2355    /**
2356     * How many files this file upload field accepts.
2357     *
2358     * Read from the block's `maxfiles` attribute and clamped rather than trusted: the value reaches
2359     * PHP as author-supplied shortcode text, and both the rendered `multiple` attribute and the
2360     * submission-time count check are derived from it.
2361     *
2362     * @since 8.0.0
2363     *
2364     * @return int A number between 1 and FILE_FIELD_MAX_FILES_LIMIT.
2365     */
2366    private function get_file_field_max_files() {
2367        $max_files = $this->get_attribute( 'maxfiles' );
2368
2369        if ( ! is_numeric( $max_files ) ) {
2370            return self::FILE_FIELD_DEFAULT_MAX_FILES;
2371        }
2372
2373        return max( 1, min( (int) $max_files, self::get_file_field_max_files_limit() ) );
2374    }
2375
2376    /**
2377     * The highest number of files an author may set a single file upload field to.
2378     *
2379     * @since 8.0.0
2380     *
2381     * @return int A number between 1 and FILE_FIELD_MAX_FILES_ABSOLUTE_LIMIT.
2382     */
2383    public static function get_file_field_max_files_limit() {
2384        /**
2385         * Filters the highest number of files an author may set a file upload field to.
2386         *
2387         * This raises the ceiling rather than setting the count. A field still takes whatever its
2388         * author chose, so one deliberately set to a single file keeps taking a single file â€” which
2389         * is the whole difference between offering more room and overriding everybody's choice.
2390         *
2391         * The editor's control reads the same number, so what an author is offered is what the site
2392         * will honour. A field already storing a number above the ceiling is clamped down to it, so
2393         * removing this filter returns those fields to the default rather than leaving them on a
2394         * value nothing enforces any more.
2395         *
2396         * Bounded by FILE_FIELD_MAX_FILES_ABSOLUTE_LIMIT. Nothing at the receiving end refuses a
2397         * submission for holding too many files â€” the endpoint's per-site budgets log and alert but
2398         * store the file regardless â€” so this ceiling is the only thing standing in the way.
2399         *
2400         * @since 8.0.0
2401         *
2402         * @param int $limit The highest number of files an author may choose.
2403         */
2404        $limit = (int) apply_filters(
2405            'jetpack_forms_file_field_max_files_limit',
2406            self::FILE_FIELD_MAX_FILES_LIMIT
2407        );
2408
2409        return max( 1, min( $limit, self::FILE_FIELD_MAX_FILES_ABSOLUTE_LIMIT ) );
2410    }
2411
2412    /**
2413     * The largest file a file upload field accepts, in bytes.
2414     *
2415     * @since 8.0.0
2416     *
2417     * @return int Size in bytes.
2418     */
2419    private static function get_file_field_max_upload_size() {
2420        /**
2421         * Filters the largest file a file upload field accepts, in bytes.
2422         *
2423         * Deliberately not bounded by the site's own PHP upload limits. The browser sends each file
2424         * straight to the upload endpoint, so `upload_max_filesize` and `post_max_size` have nothing
2425         * to do with this transfer â€” clamping to them would cap the field at whatever a cheap host
2426         * allows for files that host never receives.
2427         *
2428         * The bound that does matter is the endpoint's own, which rejects anything over
2429         * FILE_FIELD_MAX_UPLOAD_SIZE. So this filter can only lower the limit, never raise it: a
2430         * larger value would have the field accept a file, show the visitor a size allowance in the
2431         * "file is too large" message that nothing can honour, and then fail the upload once they had
2432         * already waited for it. Raising the ceiling means raising it at the endpoint first.
2433         *
2434         * The result reaches the browser as the `maxUploadSize` config value, and the "file is too
2435         * large" message is built from the same number, so the two cannot disagree.
2436         *
2437         * @since 8.0.0
2438         *
2439         * @param int $max_upload_size Maximum size of a single file, in bytes.
2440         */
2441        $max_upload_size = (int) apply_filters(
2442            'jetpack_forms_file_field_max_upload_size',
2443            self::FILE_FIELD_MAX_UPLOAD_SIZE
2444        );
2445
2446        return max( 1, min( $max_upload_size, self::FILE_FIELD_MAX_UPLOAD_SIZE ) );
2447    }
2448
2449    /**
2450     * MIME types the file upload field accepts.
2451     *
2452     * Shared by the input's `accept` attribute and by the field's `fieldExtra` config, so the
2453     * browser-side picker filter and the client-side check can never drift apart.
2454     *
2455     * @since 8.0.0
2456     *
2457     * @return string[] List of accepted MIME types.
2458     */
2459    private static function get_file_field_accepted_mime_types() {
2460        return array_values(
2461            array(
2462                'jpg|jpeg|jpe'    => 'image/jpeg',
2463                'png'             => 'image/png',
2464                'gif'             => 'image/gif',
2465                'pdf'             => 'application/pdf',
2466                'doc'             => 'application/msword',
2467                'docx'            => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
2468                'docm'            => 'application/vnd.ms-word.document.macroEnabled.12',
2469                'pot|pps|ppt'     => 'application/vnd.ms-powerpoint',
2470                'pptx'            => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
2471                'pptm'            => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
2472                'odt'             => 'application/vnd.oasis.opendocument.text',
2473                'ppsx'            => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
2474                'ppsm'            => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
2475                'csv'             => 'text/csv',
2476                'xla|xls|xlt|xlw' => 'application/vnd.ms-excel',
2477                'xlsx'            => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
2478                'xlsm'            => 'application/vnd.ms-excel.sheet.macroEnabled.12',
2479                'xlsb'            => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
2480                'key'             => 'application/vnd.apple.keynote',
2481                'webp'            => 'image/webp',
2482                'heic'            => 'image/heic',
2483                'heics'           => 'image/heic-sequence',
2484                'heif'            => 'image/heif',
2485                'heifs'           => 'image/heif-sequence',
2486                'asc'             => 'application/pgp-keys',
2487            )
2488        );
2489    }
2490
2491    /**
2492     * Enqueues scripts and styles needed for the file field.
2493     *
2494     * @since 0.45.0
2495     *
2496     * @return void
2497     */
2498    private function enqueue_file_field_assets() {
2499        $version = Constants::get_constant( 'JETPACK__VERSION' );
2500
2501        // extra cache busting strategy for view.js, seems they are left out of cache clearing on deploys
2502        $asset_file = plugin_dir_path( __FILE__ ) . '../../dist/modules/file-field/view.asset.php';
2503        $asset      = file_exists( $asset_file ) ? require $asset_file : null;
2504        $version   .= $asset['version'] ?? '';
2505
2506        \wp_enqueue_script_module(
2507            'jetpack-form-file-field',
2508            plugins_url( '../../dist/modules/file-field/view.js', __FILE__ ),
2509            // `jp-forms-view` is not decoration: this module contributes `state.validators.file`,
2510            // which `registerField()` reads at `data-wp-init` time. Loading after hydration would
2511            // leave a required file field registering as valid, since the shared `validateField()`
2512            // no longer has a `file` branch to fall back on. Same reason `field-phone` declares it.
2513            array( '@wordpress/interactivity', 'jp-forms-view' ),
2514            $version
2515        );
2516
2517        \wp_enqueue_style(
2518            'jetpack-form-file-field',
2519            plugins_url( '../../dist/contact-form/css/file-field.css', __FILE__ ),
2520            array(),
2521            $version
2522        );
2523    }
2524
2525    /**
2526     * Returns the URL for the unauthenticated file upload endpoint.
2527     *
2528     * @return string
2529     */
2530    private function get_unauth_endpoint_url() {
2531        // Return a placeholder URL if Jetpack is not active
2532        if ( ! defined( 'JETPACK__PLUGIN_DIR' ) ) {
2533            return '#jetpack-not-active';
2534        }
2535
2536        return sprintf( 'https://public-api.wordpress.com/wpcom/v2/sites/%d/unauth-file-upload', \Jetpack_Options::get_option( 'id' ) );
2537    }
2538
2539    /**
2540     * Return the HTML for the multiple checkbox field.
2541     *
2542     * @param string $id - the ID (starts with 'g' - see constructor).
2543     * @param string $label - the label.
2544     * @param string $value - the value of the field.
2545     * @param string $class - the field class.
2546     * @param bool   $required - if the field is marked as required.
2547     * @param string $required_field_text - the text in the required text field.
2548     * @param bool   $required_indicator Whether to display the required indicator.
2549     *
2550     * @return string HTML
2551     */
2552    public function render_checkbox_multiple_field( $id, $label, $value, $class, $required, $required_field_text, $required_indicator = true ) {
2553        $options_classes   = $this->get_attribute( 'optionsclasses' );
2554        $options_styles    = $this->get_attribute( 'optionsstyles' );
2555        $form_style        = $this->get_form_style();
2556        $is_outlined_style = 'outlined' === $form_style;
2557
2558        /*
2559         * The `data-required` attribute is used in `accessible-form.js` to ensure at least one
2560         * checkbox is checked. Unlike radio buttons, for which the required attribute is satisfied if
2561         * any of the radio buttons in the group is selected, adding a required attribute directly to
2562         * a checkbox means that this specific checkbox must be checked.
2563         */
2564        $fieldset_id = "id='" . esc_attr( "$id-label" ) . "'" . $this->get_hidden_label_aria_label_attr();
2565
2566        if ( $is_outlined_style ) {
2567            $style_variation_attributes = $this->get_attribute( 'stylevariationattributes' );
2568
2569            if ( ! empty( $style_variation_attributes ) ) {
2570                $style_variation_attributes = json_decode( html_entity_decode( $style_variation_attributes, ENT_COMPAT ), true );
2571            }
2572
2573            /*
2574             * When there's an outlined style, and border radius is set, the existing inline border radius is overridden to apply
2575             * a limit of `100px` to the radius on the x axis. This achieves the same look and feel as other fields
2576             * that use the notch html (`notched-label__leading` has a max-width of `100px` to prevent it from getting too wide).
2577             * It prevents large border radius values from disrupting the look and feel of the fields.
2578             */
2579            if ( isset( $style_variation_attributes['border']['radius'] ) ) {
2580                $options_styles        ??= '';
2581                $radius                  = $style_variation_attributes['border']['radius'];
2582                $has_split_radius_values = is_array( $radius );
2583                $top_left_radius         = $has_split_radius_values ? $radius['topLeft'] : $radius;
2584                $top_right_radius        = $has_split_radius_values ? $radius['topRight'] : $radius;
2585                $bottom_left_radius      = $has_split_radius_values ? $radius['bottomLeft'] : $radius;
2586                $bottom_right_radius     = $has_split_radius_values ? $radius['bottomRight'] : $radius;
2587                $options_styles         .= "border-top-left-radius: min(100px, {$top_left_radius}) {$top_left_radius};";
2588                $options_styles         .= "border-top-right-radius: min(100px, {$top_right_radius}) {$top_right_radius};";
2589                $options_styles         .= "border-bottom-left-radius: min(100px, {$bottom_left_radius}) {$bottom_left_radius};";
2590                $options_styles         .= "border-bottom-right-radius: min(100px, {$bottom_right_radius}) {$bottom_right_radius};";
2591            }
2592
2593            /*
2594             * For the "outlined" style, the styles and classes are applied to the fieldset element.
2595             */
2596            $field = "<fieldset {$fieldset_id} class='grunion-checkbox-multiple-options " . esc_attr( $options_classes ) . "' style='" . esc_attr( $options_styles ) . "' " . ( $required ? 'data-required' : '' ) . ' data-wp-bind--aria-invalid="state.fieldAriaInvalid">';
2597        } else {
2598            $field = "<fieldset {$fieldset_id} class='jetpack-field-multiple__fieldset'" . ( $required ? 'data-required' : '' ) . ' data-wp-bind--aria-invalid="state.fieldAriaInvalid">';
2599        }
2600
2601        $field .= $this->render_legend_as_label( '', $id, $label, $required, $required_field_text, array(), $required_indicator );
2602
2603        if ( ! $is_outlined_style ) {
2604            $field .= "<div class='grunion-checkbox-multiple-options " . esc_attr( $options_classes ) . "' style='" . esc_attr( $options_styles ) . "' " . '>';
2605        }
2606
2607        $options_data  = $this->get_attribute( 'optionsdata' );
2608        $used_html_ids = array();
2609
2610        if ( ! empty( $options_data ) ) {
2611            foreach ( $options_data as $option_index => $option ) {
2612                $option_label = Contact_Form_Plugin::strip_tags( $option['label'] );
2613                if ( is_string( $option_label ) && '' !== $option_label ) {
2614                    $checkbox_value = $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option_label );
2615                    $checkbox_id    = $id . '-' . sanitize_html_class( $checkbox_value );
2616
2617                    // If exact id was already used in this checkbox group, append option index.
2618                    // Multiple 'blue' options would give id-blue, id-blue-1, id-blue-2, etc.
2619                    if ( isset( $used_html_ids[ $checkbox_id ] ) ) {
2620                        $checkbox_id .= '-' . $option_index;
2621                    }
2622                    $used_html_ids[ $checkbox_id ] = true;
2623
2624                    $default_classes = 'contact-form-field';
2625                    $option_styles   = empty( $option['style'] ) ? '' : "style='" . esc_attr( $option['style'] ) . "'";
2626                    $option_classes  = empty( $option['class'] ) ? $default_classes : $default_classes . ' ' . esc_attr( $option['class'] );
2627
2628                    $field .= "<label {$option_styles} class='{$option_classes}'>";
2629                    $field .= "<input
2630                                id='" . esc_attr( $checkbox_id ) . "'
2631                                type='checkbox'
2632                                data-wp-on--change='actions.onMultipleFieldChange'
2633                                name='" . esc_attr( $id ) . "[]'
2634                                value='" . esc_attr( $checkbox_value ) . "' "
2635                                . $class
2636                                . checked( in_array( $option_label, (array) $value, true ), true, false )
2637                                . ' /> ';
2638                    $field .= "<span class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
2639                    $field .= "<span class='grunion-field-text'>" . esc_html( $option_label ) . '</span>';
2640                    $field .= '</span>';
2641                    $field .= '</label>';
2642                }
2643            }
2644        } else {
2645            $field_style = 'style="' . $this->option_styles . '"';
2646
2647            foreach ( (array) $this->get_attribute( 'options' ) as $option_index => $option ) {
2648                $option = Contact_Form_Plugin::strip_tags( $option );
2649                if ( is_string( $option ) && '' !== $option ) {
2650                    $checkbox_value = $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option );
2651                    $checkbox_id    = $id . '-' . sanitize_html_class( $checkbox_value );
2652
2653                    // If exact id was already used in this checkbox group, append option index.
2654                    // Multiple 'blue' options would give id-blue, id-blue-1, id-blue-2, etc.
2655                    if ( isset( $used_html_ids[ $checkbox_id ] ) ) {
2656                        $checkbox_id .= '-' . $option_index;
2657                    }
2658                    $used_html_ids[ $checkbox_id ] = true;
2659
2660                    $field .= "<label class='contact-form-field'>";
2661                    $field .= "<input
2662                                id='" . esc_attr( $checkbox_id ) . "'
2663                                data-wp-on--change='actions.onMultipleFieldChange'
2664                                type='checkbox'
2665                                name='" . esc_attr( $id ) . "[]'
2666                                value='" . esc_attr( $checkbox_value ) . "' "
2667                                . $class
2668                                . checked( in_array( $option, (array) $value, true ), true, false )
2669                                . ' /> ';
2670                    $field .= "<span {$field_style} class='grunion-checkbox-multiple-label checkbox-multiple" . ( $this->is_error() ? ' form-error' : '' ) . "'>";
2671                    $field .= "<span class='grunion-field-text'>" . esc_html( $option ) . '</span>';
2672                    $field .= '</span>';
2673                    $field .= '</label>';
2674                }
2675            }
2676        }
2677        if ( ! $is_outlined_style ) {
2678            $field .= '</div>';
2679        }
2680        $field .= $this->get_error_div( $id, 'select' ) . '</fieldset>';
2681        return $field;
2682    }
2683
2684    /**
2685     * Return the HTML for the select field.
2686     *
2687     * @param int    $id - the ID.
2688     * @param string $label - the label.
2689     * @param string $value - the value of the field.
2690     * @param string $class - the field class.
2691     * @param bool   $required - if the field is marked as required.
2692     * @param string $required_field_text - the text in the required text field.
2693     * @param bool   $required_indicator Whether to display the required indicator.
2694     *
2695     * @return string HTML
2696     */
2697    public function render_select_field( $id, $label, $value, $class, $required, $required_field_text, $required_indicator = true ) {
2698        $field      = $this->render_label( 'select', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
2699        $class      = preg_replace( "/class=['\"]([^'\"]*)['\"]/", 'class="contact-form__select-wrapper $1"', $class );
2700        $field     .= "<div {$class} style='" . esc_attr( $this->field_styles ) . "'>";
2701        $aria_label = ! empty( $this->get_attribute( 'togglelabel' ) )
2702            ? Contact_Form_Plugin::strip_tags( $this->get_attribute( 'togglelabel' ) )
2703            : __( 'Select an option', 'jetpack-forms' ); // selects don't have a default label
2704        $field     .= "\t<span class='contact-form__select-element-wrapper'><select name='" . esc_attr( $id ) . "' id='" . esc_attr( $id ) . "' " . ( $required ? "required aria-required='true'" : '' ) . " data-wp-on--change='actions.onFieldChange' data-wp-bind--aria-invalid='state.fieldAriaInvalid' aria-describedby='" . esc_attr( $this->get_described_by( $id, 'select' ) ) . "' " . $this->get_hidden_label_aria_label_attr( $aria_label ) . ">\n";
2705
2706        if ( $this->get_attribute( 'togglelabel' ) ) {
2707            $field .= "\t\t<option value=''>" . $this->get_attribute( 'togglelabel' ) . "</option>\n";
2708        } elseif ( ! $this->get_attribute( 'default' ) ) {
2709            // For select fields without an explicit togglelabel or default value (e.g., shortcode-based forms),
2710            // add a placeholder option to ensure users must make an explicit selection.
2711            $field .= "\t\t<option value=''>" . esc_html__( 'Select an option', 'jetpack-forms' ) . "</option>\n";
2712        }
2713
2714        foreach ( (array) $this->get_attribute( 'options' ) as $option_index => $option ) {
2715            $option = Contact_Form_Plugin::strip_tags( $option );
2716            if ( is_string( $option ) && $option !== '' ) {
2717                $field .= "\t\t<option"
2718                                . selected( $option, $value, false )
2719                                . " value='" . esc_attr( $this->get_option_value( $this->get_attribute( 'values' ), $option_index, $option ) )
2720                                . "'>" . esc_html( $option )
2721                                . "</option>\n";
2722            }
2723        }
2724        $field .= "\t</select><span class='jetpack-field-dropdown__icon'></span></span>\n";
2725        $field .= "</div>\n";
2726
2727        return $field . $this->get_field_descriptions( $id, 'select' );
2728    }
2729
2730    /**
2731     * The date formats the date field supports, keyed by the jQuery-style
2732     * format string stored in the `dateformat` attribute.
2733     *
2734     * WARNING: sync data with DATE_FORMATS in src/blocks/shared/util/constants.js
2735     *
2736     * @since 8.0.0
2737     *
2738     * @return array
2739     */
2740    private static function get_date_formats() {
2741        return array(
2742            /* translators: date format. DD is the day of the month, MM the month, and YYYY the year (e.g., 12/31/2023). */
2743            'mm/dd/yy' => __( 'MM/DD/YYYY', 'jetpack-forms' ),
2744            /* translators: date format. DD is the day of the month, MM the month, and YYYY the year (e.g., 31/12/2023). */
2745            'dd/mm/yy' => __( 'DD/MM/YYYY', 'jetpack-forms' ),
2746            /* translators: date format. DD is the day of the month, MM the month, and YYYY the year (e.g., 2023-12-31). */
2747            'yy-mm-dd' => __( 'YYYY-MM-DD', 'jetpack-forms' ),
2748        );
2749    }
2750
2751    /**
2752     * The field's date format key, falling back to the default.
2753     *
2754     * Shared by the visible hint and the `data-format` attribute that drives
2755     * the picker, so the two cannot disagree about which format is in effect.
2756     *
2757     * @since 8.0.0
2758     *
2759     * @return string
2760     */
2761    private function get_date_format() {
2762        $date_format = $this->get_attribute( 'dateformat' );
2763
2764        return ! empty( $date_format ) ? $date_format : 'yy-mm-dd';
2765    }
2766
2767    /**
2768     * Return the HTML for the date field.
2769     *
2770     * @param int    $id - the ID.
2771     * @param string $label - the label.
2772     * @param string $value - the value of the field.
2773     * @param string $class - the field class.
2774     * @param bool   $required - if the field is marked as required.
2775     * @param string $required_field_text - the text in the required text field.
2776     * @param string $placeholder - the field placeholder content.
2777     * @param bool   $required_indicator Whether to display the required indicator.
2778     *
2779     * @return string HTML
2780     */
2781    public function render_date_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $required_indicator = true ) {
2782        static $is_loaded = false;
2783        $this->set_invalid_message( 'date', __( 'Please enter a valid date.', 'jetpack-forms' ) );
2784
2785        $date_format = $this->get_date_format();
2786        $extra_attrs = array( 'data-format' => $date_format );
2787
2788        $field  = $this->render_label( 'date', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
2789        $field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required, $extra_attrs );
2790
2791        /* For AMP requests, use amp-date-picker element: https://amp.dev/documentation/components/amp-date-picker */
2792        if ( class_exists( 'Jetpack_AMP_Support' ) && \Jetpack_AMP_Support::is_amp_request() ) {
2793            return sprintf(
2794                '<%1$s mode="overlay" layout="container" type="single" input-selector="[name=%2$s]">%3$s</%1$s>',
2795                'amp-date-picker',
2796                esc_attr( $id ),
2797                $field
2798            );
2799        }
2800
2801        Assets::register_script(
2802            'jp-forms-date-picker',
2803            '../../dist/contact-form/js/date-picker.js',
2804            __FILE__,
2805            array(
2806                'enqueue'      => true,
2807                'dependencies' => array(),
2808                'version'      => Constants::get_constant( 'JETPACK__VERSION' ),
2809            )
2810        );
2811
2812        /**
2813         * Filter the localized date picker script.
2814         */
2815        if ( ! $is_loaded ) {
2816            \wp_localize_script(
2817                'jp-forms-date-picker',
2818                'jpDatePicker',
2819                array(
2820                    'offset' => intval( get_option( 'start_of_week', 1 ) ),
2821                    'lang'   => array(
2822                        // translators: These are the two letter abbreviated name of the week.
2823                        'days'      => array(
2824                            __( 'Su', 'jetpack-forms' ),
2825                            __( 'Mo', 'jetpack-forms' ),
2826                            __( 'Tu', 'jetpack-forms' ),
2827                            __( 'We', 'jetpack-forms' ),
2828                            __( 'Th', 'jetpack-forms' ),
2829                            __( 'Fr', 'jetpack-forms' ),
2830                            __( 'Sa', 'jetpack-forms' ),
2831                        ),
2832                        'months'    => array(
2833                            __( 'January', 'jetpack-forms' ),
2834                            __( 'February', 'jetpack-forms' ),
2835                            __( 'March', 'jetpack-forms' ),
2836                            __( 'April', 'jetpack-forms' ),
2837                            __( 'May', 'jetpack-forms' ),
2838                            __( 'June', 'jetpack-forms' ),
2839                            __( 'July', 'jetpack-forms' ),
2840                            __( 'August', 'jetpack-forms' ),
2841                            __( 'September', 'jetpack-forms' ),
2842                            __( 'October', 'jetpack-forms' ),
2843                            __( 'November', 'jetpack-forms' ),
2844                            __( 'December', 'jetpack-forms' ),
2845                        ),
2846                        'today'     => __( 'Today', 'jetpack-forms' ),
2847                        'clear'     => __( 'Clear', 'jetpack-forms' ),
2848                        'close'     => __( 'Close', 'jetpack-forms' ),
2849                        'ariaLabel' => array(
2850                            'dayPicker'         => __( 'You are currently inside the date picker, use the arrow keys to navigate between the dates. Use tab key to jump to more controls.', 'jetpack-forms' ),
2851                            'monthPicker'       => __( 'You are currently inside the month picker, use the arrow keys to navigate between the months. Use the space key to select it.', 'jetpack-forms' ),
2852                            'yearPicker'        => __( 'You are currently inside the year picker, use the up and down arrow keys to navigate between the years. Use the space key to select it.', 'jetpack-forms' ),
2853                            'monthPickerButton' => __( 'Month picker. Use the space key to enter the month picker.', 'jetpack-forms' ),
2854                            'yearPickerButton'  => __( 'Year picker. Use the space key to enter the month picker.', 'jetpack-forms' ),
2855                            'dayButton'         => __( 'Use the space key to select the date.', 'jetpack-forms' ),
2856                            'todayButton'       => __( 'Today button. Use the space key to select the current date.', 'jetpack-forms' ),
2857                            'clearButton'       => __( 'Clear button. Use the space key to clear the date picker.', 'jetpack-forms' ),
2858                            'closeButton'       => __( 'Close button. Use the space key to close the date picker.', 'jetpack-forms' ),
2859                        ),
2860                    ),
2861                )
2862            );
2863            $is_loaded = true;
2864        }
2865
2866        return $field;
2867    }
2868
2869    /**
2870     * Return the HTML for the time field.
2871     *
2872     * @param int    $id - the ID.
2873     * @param string $label - the label.
2874     * @param string $value - the value of the field.
2875     * @param string $class - the field class.
2876     * @param bool   $required - if the field is marked as required.
2877     * @param string $required_field_text - the text in the required text field.
2878     * @param string $placeholder - the field placeholder content.
2879     * @param bool   $required_indicator Whether to display the required indicator.
2880     *
2881     * @return string HTML
2882     */
2883    public function render_time_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $required_indicator = true ) {
2884        $this->set_invalid_message( 'time', __( 'Please enter a valid time.', 'jetpack-forms' ) );
2885
2886        $field  = $this->render_label( 'time', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
2887        $field .= $this->render_input_field( 'time', $id, $value, $class, $placeholder, $required );
2888
2889        return $field;
2890    }
2891
2892    /**
2893     * Return the HTML for the image select field.
2894     *
2895     * @param int    $id - the ID.
2896     * @param string $label - the label.
2897     * @param string $value - the value of the field.
2898     * @param string $class - the field class.
2899     * @param bool   $required - if the field is marked as required.
2900     * @param string $required_field_text - the text in the required text field.
2901     * @param bool   $required_indicator Whether to display the required indicator.
2902     *
2903     * @return string HTML
2904     */
2905    public function render_image_select_field( $id, $label, $value, $class, $required, $required_field_text, $required_indicator = true ) {
2906        wp_enqueue_style( 'jetpack-form-field-image-select-style', plugins_url( '../../dist/blocks/field-image-select/style.css', __FILE__ ), array(), Constants::get_constant( 'JETPACK__VERSION' ) );
2907
2908        $is_multiple       = $this->get_attribute( 'ismultiple' );
2909        $show_labels       = $this->get_attribute( 'showlabels' );
2910        $randomize_options = $this->get_attribute( 'randomizeoptions' );
2911        $is_supersized     = $this->get_attribute( 'issupersized' );
2912
2913        $input_type = $is_multiple ? 'checkbox' : 'radio';
2914        $input_name = $is_multiple ? $id . '[]' : $id;
2915
2916        $field = "<div class='jetpack-field jetpack-field-image-select'>";
2917
2918        $fieldset_id = "id='" . esc_attr( "$id-label" ) . "'" . $this->get_hidden_label_aria_label_attr();
2919
2920        $field .= "<fieldset {$fieldset_id} data-wp-bind--aria-invalid='state.fieldAriaInvalid' >";
2921
2922        $field .= $this->render_legend_as_label( '', $id, $label, $required, $required_field_text, array(), $required_indicator );
2923
2924        $options_classes = $this->get_attribute( 'optionsclasses' );
2925        $options_styles  = $this->get_attribute( 'optionsstyles' );
2926
2927        $field .= "<div class='" . esc_attr( $options_classes ) . " jetpack-field jetpack-fieldset-image-options' style='" . esc_attr( $options_styles ) . "'>";
2928        $field .= "<div class='jetpack-fieldset-image-options__wrapper'>";
2929
2930        $options_data = $this->get_attribute( 'optionsdata' );
2931
2932        // Filter out empty options from the end
2933        $options_data = $this->trim_image_select_options( $options_data );
2934
2935        $used_html_ids = array();
2936
2937        // Create a separate array of original letters in sequence (A, B, C...)
2938        $perceived_letters = array();
2939
2940        foreach ( $options_data as $option ) {
2941            $perceived_letters[] = Contact_Form_Plugin::strip_tags( $option['letter'] );
2942        }
2943
2944        // Create a working copy of options for potential randomization
2945        $working_options = $options_data;
2946
2947        // Randomize options if requested, but preserve original letter values
2948        if ( $randomize_options ) {
2949            shuffle( $working_options );
2950
2951            // Trims options after randomization to ensure the last option has a label or image.
2952            $working_options = $this->trim_image_select_options( $working_options );
2953        }
2954
2955        // Calculate row options count for CSS variable
2956        $total_options_count = count( $working_options );
2957        // Those values are halved on mobile via CSS media query
2958        $max_images_per_row = $is_supersized ? 2 : 4;
2959        $row_options_count  = min( $total_options_count, $max_images_per_row );
2960
2961        foreach ( $working_options as $option_index => $option ) {
2962            $option_label  = Contact_Form_Plugin::strip_tags( $option['label'] );
2963            $option_letter = Contact_Form_Plugin::strip_tags( $option['letter'] );
2964            $image_block   = $option['image'] ?? null;
2965            $image_id      = ( is_array( $image_block ) && isset( $image_block['attrs'] ) && is_array( $image_block['attrs'] ) ) ? ( $image_block['attrs']['id'] ?? null ) : null;
2966
2967            $rendered_image_block = is_array( $image_block ) ? render_block( $image_block ) : '';
2968            // Remove any links from the rendered block
2969            $rendered_image_block = preg_replace( '/<a[^>]*>(.*?)<\/a>/s', '$1', $rendered_image_block );
2970
2971            // Extract image src from rendered block
2972            $image_src = '';
2973
2974            if ( ! empty( $rendered_image_block ) ) {
2975                if ( preg_match( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $rendered_image_block, $matches ) ) {
2976                    $extracted_src = $matches[1];
2977
2978                    if ( filter_var( $extracted_src, FILTER_VALIDATE_URL ) || str_starts_with( $extracted_src, 'data:' ) ) {
2979                        $image_src = $extracted_src;
2980                    }
2981                }
2982            } else {
2983                $rendered_image_block = '<figure class="wp-block-image jetpack-input-image-option__empty-image"><img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" alt="" style="aspect-ratio:1;object-fit:cover"/></figure>';
2984            }
2985
2986            $option_value                = wp_json_encode(
2987                array(
2988                    'perceived'  => $perceived_letters[ $option_index ],
2989                    'selected'   => $option_letter,
2990                    'label'      => $option_label,
2991                    'showLabels' => $show_labels,
2992                    'image'      => array(
2993                        'id'  => $image_id,
2994                        'src' => $image_src ?? null,
2995                    ),
2996                ),
2997                JSON_HEX_AMP | JSON_UNESCAPED_SLASHES
2998            );
2999            $option_id                   = $id . '-' . $option_letter;
3000            $used_html_ids[ $option_id ] = true;
3001
3002            $figcaption_id = esc_attr( $option_id . '-figcaption' );
3003
3004            // Add id attribute to figcaption for accessibility
3005            if ( ! empty( $rendered_image_block ) && strpos( $rendered_image_block, '<figcaption' ) !== false ) {
3006                $rendered_image_block = preg_replace( '/(<figcaption[^>]*)(>)/', '$1 id="' . $figcaption_id . '"$2', $rendered_image_block );
3007            }
3008
3009            // To be able to apply the backdrop-filter for the hover effect, we need to separate the background into an outer div.
3010            // This outer div needs the color styles separately, and also the border radius to match the inner div without sticking out.
3011            $option_outer_classes = 'jetpack-input-image-option__outer ' . ( $option['classcolor'] ?? '' );
3012
3013            if ( $is_supersized ) {
3014                $option_outer_classes .= ' is-supersized';
3015            }
3016
3017            $border_styles = '';
3018            if ( ! empty( $option['style'] ) ) {
3019                preg_match( '/border-radius:([^;]+)/', $option['style'], $radius_match );
3020                preg_match( '/border-width:([^;]+)/', $option['style'], $width_match );
3021
3022                if ( ! empty( $radius_match[1] ) ) {
3023                    $radius_value = trim( $radius_match[1] );
3024
3025                    if ( ! empty( $width_match[1] ) ) {
3026                            $width_value   = trim( $width_match[1] );
3027                            $border_styles = "border-radius:calc({$radius_value} + {$width_value});";
3028                    } else {
3029                            $border_styles = "border-radius:{$radius_value};";
3030                    }
3031                } else {
3032                    // Handle individual border radius properties when border-radius is not present
3033                    preg_match( '/border-top-left-radius:([^;]+)/', $option['style'], $top_left_match );
3034                    preg_match( '/border-top-right-radius:([^;]+)/', $option['style'], $top_right_match );
3035                    preg_match( '/border-bottom-right-radius:([^;]+)/', $option['style'], $bottom_right_match );
3036                    preg_match( '/border-bottom-left-radius:([^;]+)/', $option['style'], $bottom_left_match );
3037
3038                    if ( ! empty( $top_left_match[1] ) || ! empty( $top_right_match[1] ) || ! empty( $bottom_right_match[1] ) || ! empty( $bottom_left_match[1] ) ) {
3039                        $width_value = ! empty( $width_match[1] ) ? trim( $width_match[1] ) : '1px';
3040
3041                        $top_left     = ! empty( $top_left_match[1] ) ? trim( $top_left_match[1] ) : '4px';
3042                        $top_right    = ! empty( $top_right_match[1] ) ? trim( $top_right_match[1] ) : '4px';
3043                        $bottom_right = ! empty( $bottom_right_match[1] ) ? trim( $bottom_right_match[1] ) : '4px';
3044                        $bottom_left  = ! empty( $bottom_left_match[1] ) ? trim( $bottom_left_match[1] ) : '4px';
3045
3046                        $border_styles = "border-radius:calc({$top_left} + {$width_value}) calc({$top_right} + {$width_value}) calc({$bottom_right} + {$width_value}) calc({$bottom_left} + {$width_value});";
3047                    }
3048                }
3049            }
3050
3051            $option_outer_styles  = ( empty( $option['stylecolor'] ) ? '' : $option['stylecolor'] ) . $border_styles;
3052            $option_outer_styles .= "--row-options-count: {$row_options_count};";
3053            $option_outer_styles  = empty( $option_outer_styles ) ? '' : "style='" . esc_attr( $option_outer_styles ) . "'";
3054
3055            $field .= "<div class='{$option_outer_classes}' {$option_outer_styles}>";
3056
3057            $default_classes = 'jetpack-field jetpack-input-image-option';
3058            $option_styles   = empty( $option['style'] ) ? '' : "style='" . esc_attr( $option['style'] ) . "'";
3059            $option_classes  = "class='" . ( empty( $option['class'] ) ? $default_classes : $default_classes . ' ' . esc_attr( $option['class'] ) ) . "'";
3060
3061            $field .= "<div {$option_classes} {$option_styles} data-wp-on--click='actions.onImageOptionClick' data-wp-init='callbacks.setImageOptionOutlineColor'>";
3062
3063            $input_id = esc_attr( $option_id );
3064            $label_id = esc_attr( $option_id . '-label' );
3065
3066            /* translators: %s is the letter associated with the option, e.g. "Option A" */
3067            $aria_label_parts = array( sprintf( __( 'Option %s', 'jetpack-forms' ), $perceived_letters[ $option_index ] ) );
3068
3069            if ( ! empty( $option_label ) ) {
3070                $aria_label_parts[] = $option_label;
3071            }
3072
3073            $aria_label = implode( ': ', $aria_label_parts );
3074
3075            // Build aria-describedby to reference label and figcaption
3076            $aria_describedby_parts = array( $label_id );
3077
3078            if ( ! empty( $rendered_image_block ) && strpos( $rendered_image_block, '<figcaption' ) !== false ) {
3079                $aria_describedby_parts[] = $figcaption_id;
3080            }
3081
3082            $aria_describedby = implode( ' ', $aria_describedby_parts );
3083
3084            $field .= "<div class='jetpack-input-image-option__wrapper'>";
3085            $field .= "<input
3086            id='" . $input_id . "'
3087            class='jetpack-input-image-option__input'
3088            type='" . esc_attr( $input_type ) . "'
3089            name='" . esc_attr( $input_name ) . "'
3090            value='" . esc_attr( $option_value ) . "'
3091            aria-label='" . esc_attr( $aria_label ) . "'
3092            aria-describedby='" . esc_attr( $aria_describedby ) . "'
3093            data-wp-init='callbacks.setImageOptionCheckColor'
3094            data-wp-on--keydown='actions.onKeyDownImageOption'
3095            data-wp-on--change='" . ( $is_multiple ? 'actions.onMultipleFieldChange' : 'actions.onFieldChange' ) . "' "
3096            . $class
3097            . ( $is_multiple ? checked( in_array( $option_value, (array) $value, true ), true, false ) : checked( $option_value, $value, false ) ) . ' '
3098            . ( $required ? "required aria-required='true'" : '' )
3099            . '/> ';
3100
3101            $field .= $rendered_image_block;
3102            $field .= '</div>';
3103
3104            $field .= "<div class='jetpack-input-image-option__label-wrapper'>";
3105            $field .= "<div class='jetpack-input-image-option__label-code'>" . esc_html( $perceived_letters[ $option_index ] ) . '</div>';
3106
3107            $label_classes  = 'jetpack-input-image-option__label';
3108            $label_classes .= $show_labels ? '' : ' visually-hidden';
3109            $field         .= "<span id='{$label_id}' class='{$label_classes}'>" . esc_html( $option_label ) . '</span>';
3110            $field         .= '</div></div></div>';
3111        }
3112
3113        $field .= '</div></div>';
3114
3115        $field .= $this->get_error_div( $id, 'image-select' );
3116
3117        $field .= '</fieldset>';
3118
3119        $field .= '</div>';
3120
3121        return $field;
3122    }
3123
3124    /**
3125     * Return the HTML for the number field.
3126     *
3127     * @param int    $id - the ID.
3128     * @param string $label - the label.
3129     * @param string $value - the value of the field.
3130     * @param string $class - the field class.
3131     * @param bool   $required - if the field is marked as required.
3132     * @param string $required_field_text - the text in the required text field.
3133     * @param string $placeholder - the field placeholder content.
3134     * @param array  $extra_attrs - Extra attributes used in number field, namely `min` and `max`.
3135     * @param bool   $required_indicator Whether to display the required indicator.
3136     *
3137     * @return string HTML
3138     */
3139    public function render_number_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $extra_attrs = array(), $required_indicator = true ) {
3140        $this->set_invalid_message( 'number', __( 'Please enter a valid number', 'jetpack-forms' ) );
3141        if ( isset( $extra_attrs['min'] ) ) {
3142            // translators: %d is the minimum value.
3143            $this->set_invalid_message( 'min_number', __( 'Please select a value that is no less than %d.', 'jetpack-forms' ) );
3144        }
3145        if ( isset( $extra_attrs['max'] ) ) {
3146            // translators: %d is the maximum value.
3147            $this->set_invalid_message( 'max_number', __( 'Please select a value that is no more than %d.', 'jetpack-forms' ) );
3148        }
3149        $field  = $this->render_label( 'number', $id, $label, $required, $required_field_text, array(), false, $required_indicator );
3150        $field .= $this->render_input_field( 'number', $id, $value, $class, $placeholder, $required, $extra_attrs );
3151        return $field;
3152    }
3153
3154    /**
3155     * Return the HTML for the default field.
3156     *
3157     * @param int    $id - the ID.
3158     * @param string $label - the label.
3159     * @param string $value - the value of the field.
3160     * @param string $class - the field class.
3161     * @param bool   $required - if the field is marked as required.
3162     * @param string $required_field_text - the text in the required text field.
3163     * @param string $placeholder - the field placeholder content.
3164     * @param string $type - the type.
3165     * @param bool   $required_indicator Whether to display the required indicator.
3166     *
3167     * @return string HTML
3168     */
3169    public function render_default_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $type, $required_indicator = true ) {
3170        $field  = $this->render_label( $type, $id, $label, $required, $required_field_text, array(), false, $required_indicator );
3171        $field .= $this->render_input_field( 'text', $id, $value, $class, $placeholder, $required );
3172        return $field;
3173    }
3174
3175    /**
3176     * Returns the styles, classes and CSS vars necessary to render fields in the "Outlined" style.
3177     * The "Animated" style variation shares the CSS vars, which require similar calculations for the left offset and label left position.
3178     * At the block level, the styles are extracted and added to the shortcode attributes in
3179     * Contact_Form_Plugin::get_outlined_style_attributes().
3180     * This function extracts those styles and applies them to the field,
3181     * and ensures any global or theme styles are applied.
3182     *
3183     * @param string $form_style (optional) The form style.
3184     *
3185     * @return array {
3186     *     @type string $style_attrs The style attributes.
3187     *     @type string $css_vars The CSS variables.
3188     *     @type string $class_name The class name.
3189     * }
3190     */
3191    private function get_form_variation_style_properties( $form_style = 'outlined' ) {
3192        $css_vars             = '';
3193        $variation_attributes = $this->get_attribute( 'stylevariationattributes' );
3194        $variation_attributes = ! empty( $variation_attributes ) ? json_decode( html_entity_decode( $variation_attributes, ENT_COMPAT ), true ) : array();
3195        $variation_classes    = $this->get_attribute( 'stylevariationclasses' );
3196        $variation_style      = $this->get_attribute( 'stylevariationstyles' );
3197        $block_name           = 'jetpack/input';
3198
3199        if ( $this->maybe_override_type() === 'radio' || $this->maybe_override_type() === 'checkbox-multiple' ) {
3200            $block_name = 'jetpack/options';
3201        }
3202
3203        $global_styles = wp_get_global_styles(
3204            array( 'border' ),
3205            array(
3206                'block_name' => $block_name,
3207                'transforms' => array( 'resolve-variables' ),
3208            )
3209        );
3210
3211        /*
3212         * The `borderwidth` attribute contains the border value that forms used before the migration to global styles.
3213         * Any old forms saved in a post will still use this attribute, so it needs to be factored into the css vars for border
3214         * to properly support backwards compatibility. So we check if the attribute is set and if it's not empty or '0', which is a valid width value.
3215         * For newer forms that use global styles or the block supports styles, this value will be empty and is ignored.
3216         */
3217        $border_width_attribute = $this->get_attribute( 'borderwidth' );
3218        $legacy_border_size     = ! empty( $border_width_attribute ) || $border_width_attribute === '0' ? $border_width_attribute . 'px' : null;
3219
3220        $border_radius_attribute = $this->get_attribute( 'borderradius' );
3221        $legacy_border_radius    = ! empty( $border_radius_attribute ) || $border_radius_attribute === '0' ? $border_radius_attribute . 'px' : $variation_attributes['border']['radius'] ?? null;
3222
3223        $border_top_size = $legacy_border_size ??
3224            $variation_attributes['border']['width'] ??
3225            $variation_attributes['border']['top']['width'] ??
3226            $global_styles['width'] ??
3227            $global_styles['top']['width'] ?? null;
3228
3229        $border_right_size = $legacy_border_size ??
3230
3231            $variation_attributes['border']['right']['width'] ??
3232            $global_styles['width'] ??
3233            $global_styles['right']['width'] ?? null;
3234
3235        $border_bottom_size = $legacy_border_size ??
3236            $variation_attributes['border']['width'] ??
3237            $variation_attributes['border']['bottom']['width'] ??
3238            $global_styles['width'] ??
3239            $global_styles['bottom']['width'] ?? null;
3240
3241        $border_left_size = $legacy_border_size ??
3242            $variation_attributes['border']['width'] ??
3243            $variation_attributes['border']['left']['width'] ??
3244            $global_styles['width'] ??
3245            $global_styles['left']['width'] ?? null;
3246
3247        $border_radius = $legacy_border_radius ??
3248            $global_styles['radius'] ?? null;
3249
3250        // Border size to accommodate legacy border width attribute.
3251        $css_vars = $legacy_border_size ? '--jetpack--contact-form--border-size: ' . $legacy_border_size . ';' : '';
3252
3253        // Border side sizes to accommodate global styles split values.
3254        $css_vars .= $border_top_size ? '--jetpack--contact-form--border-top-size: ' . $border_top_size . ';' : '';
3255        $css_vars .= $border_right_size ? '--jetpack--contact-form--border-right-size: ' . $border_right_size . ';' : '';
3256        $css_vars .= $border_bottom_size ? '--jetpack--contact-form--border-bottom-size: ' . $border_bottom_size . ';' : '';
3257        $css_vars .= $border_left_size ? '--jetpack--contact-form--border-left-size: ' . $border_left_size . ';' : '';
3258
3259        // Check if border radius is split or a single value.
3260        if ( is_array( $border_radius ) ) {
3261            // If corner radii are set on the top-left or bottom-left of the block, take the maximum of the two.
3262            // We check the left side due to writing direction—this variable is used to offset text.
3263            // TODO: this should factor in RTL languages.
3264            $css_vars .= $border_radius ? '--jetpack--contact-form--border-radius: max(' . ( $border_radius['topLeft'] ?? '0' ) . ',' . ( $border_radius['bottomLeft'] ?? '0' ) . ');' : '';
3265        } elseif ( isset( $border_radius ) ) {
3266            $css_vars .= $border_radius ? '--jetpack--contact-form--border-radius: ' . $border_radius . ';' : '';
3267        }
3268
3269        if ( 'outlined' === $form_style ) {
3270            $css_vars .= '--jetpack--contact-form--notch-width: max(var(--jetpack--contact-form--input-padding-left, 16px), var(--jetpack--contact-form--border-radius));';
3271        } elseif ( 'animated' === $form_style ) {
3272            $css_vars .= '--jetpack--contact-form--animated-left-offset: 16px;';
3273        }
3274
3275        return array(
3276            'style'      => $variation_style,
3277            'css_vars'   => $css_vars,
3278            'class_name' => $variation_classes,
3279        );
3280    }
3281
3282    /**
3283     * Return the HTML for the outlined label.
3284     *
3285     * @param int    $id - the ID.
3286     * @param string $label - the label.
3287     * @param bool   $required - if the field is marked as required.
3288     * @param string $required_field_text - the text in the required text field.
3289     * @param bool   $required_indicator Whether to display the required indicator.
3290     *
3291     * @return string HTML
3292     */
3293    public function render_outline_label( $id, $label, $required, $required_field_text, $required_indicator = true ) {
3294        $classes  = 'notched-label__label';
3295        $classes .= $this->is_error() ? ' form-error' : '';
3296        $classes .= $this->label_classes ? ' ' . $this->label_classes : '';
3297
3298        $output_data = $this->get_form_variation_style_properties();
3299
3300        return '
3301            <div class="notched-label">
3302                <div class="notched-label__leading' . esc_attr( $output_data['class_name'] ) . '" style="' . esc_attr( $output_data['style'] ) . '"></div>
3303                <div class="notched-label__notch' . esc_attr( $output_data['class_name'] ) . '" style="' . esc_attr( $output_data['style'] ) . '">
3304                    <label
3305                        for="' . esc_attr( $id ) . '"
3306                        class=" ' . $classes . '"
3307                        style="' . $this->label_styles . esc_attr( $output_data['css_vars'] ) . '"
3308                    >
3309                    <span class="grunion-label-text">' . esc_html( $label ) . '</span>'
3310                    . ( $required && $required_indicator ? '<span class="grunion-label-required" aria-hidden="true">' . $required_field_text . '</span>' : '' ) .
3311            '</label>
3312                </div>
3313                <div class="notched-label__filler' . esc_attr( $output_data['class_name'] ) . '" style="' . esc_attr( $output_data['style'] ) . '"></div>
3314                <div class="notched-label__trailing' . esc_attr( $output_data['class_name'] ) . '" style="' . esc_attr( $output_data['style'] ) . '"></div>
3315            </div>';
3316    }
3317
3318    /**
3319     * Return the HTML for the animated label.
3320     *
3321     * @param int    $id - the ID.
3322     * @param string $label - the label.
3323     * @param bool   $required - if the field is marked as required.
3324     * @param string $required_field_text - the text in the required text field.
3325     * @param bool   $required_indicator Whether to display the required indicator.
3326     *
3327     * @return string HTML
3328     */
3329    public function render_animated_label( $id, $label, $required, $required_field_text, $required_indicator = true ) {
3330        $classes  = 'animated-label__label';
3331        $classes .= $this->is_error() ? ' form-error' : '';
3332        $classes .= $this->label_classes ? ' ' . $this->label_classes : '';
3333
3334        return '
3335            <label
3336                for="' . esc_attr( $id ) . '"
3337                class="' . $classes . '"
3338                style="' . $this->label_styles . '"
3339            >
3340                <span class="grunion-label-text">' . wp_kses_post( $label ) . '</span>'
3341                . ( $required && $required_indicator !== 'hidden' ? '<span class="grunion-label-required" aria-hidden="true">' . $required_field_text . '</span>' : '' ) .
3342            '</label>';
3343    }
3344
3345    /**
3346     * Return the HTML for the below label.
3347     *
3348     * @param int    $id - the ID.
3349     * @param string $label - the label.
3350     * @param bool   $required - if the field is marked as required.
3351     * @param string $required_field_text - the text in the required text field.
3352     * @param bool   $required_indicator Whether to display the required indicator.
3353     *
3354     * @return string HTML
3355     */
3356    public function render_below_label( $id, $label, $required, $required_field_text, $required_indicator = true ) {
3357        return '
3358            <label
3359                for="' . esc_attr( $id ) . '"
3360                class="below-label__label ' . ( $this->is_error() ? ' form-error' : '' ) . ( $this->label_classes ? ' ' . esc_attr( $this->label_classes ) : '' ) . '"
3361            >'
3362            . esc_html( $label )
3363            . ( $required && $required_indicator ? '<span>' . $required_field_text . '</span>' : '' ) .
3364            '</label>';
3365    }
3366
3367    /**
3368     * Return the HTML for the email field.
3369     *
3370     * @param string $type - the type.
3371     * @param int    $id - the ID.
3372     * @param string $label - the label.
3373     * @param string $value - the value of the field.
3374     * @param string $class - the field class.
3375     * @param string $placeholder - the field placeholder content.
3376     * @param bool   $required - if the field is marked as required.
3377     * @param string $required_field_text - the text for a field marked as required.
3378     * @param array  $extra_attrs - extra attributes to be passed to render functions.
3379     * @param bool   $required_indicator Whether to display the required indicator.
3380     *
3381     * @return string HTML
3382     */
3383    public function render_field( $type, $id, $label, $value, $class, $placeholder, $required, $required_field_text, $extra_attrs = array(), $required_indicator = true ) {
3384        if ( ! $this->is_field_renderable( $type ) ) {
3385            return '';
3386        }
3387
3388        if ( $type === 'hidden' ) {
3389            // For hidden fields, we don't need to render the label or any other HTML.
3390            return $this->render_hidden_field( $id, $label, $value );
3391        }
3392
3393        $trimmed_type = trim( esc_attr( $type ) );
3394        $class       .= ' grunion-field';
3395
3396        $form_style = $this->get_form_style();
3397        if ( ! empty( $form_style ) && $form_style !== 'default' ) {
3398            if ( isset( $placeholder ) && '' !== $placeholder ) {
3399                $class .= ' has-placeholder';
3400            }
3401        }
3402
3403        // Field classes.
3404        $field_class = "class='" . $trimmed_type . ' ' . esc_attr( $class ) . "' ";
3405
3406        // Shell wrapper classes. Add -wrap to each class.
3407        $wrap_classes          = empty( $class ) ? '' : implode( '-wrap ', array_filter( explode( ' ', $class ) ) ) . '-wrap';
3408        $field_wrapper_classes = $this->get_attribute( 'fieldwrapperclasses' ) ? $this->get_attribute( 'fieldwrapperclasses' ) . ' ' : '';
3409
3410        if ( empty( $label ) && ! $required ) {
3411            $wrap_classes .= ' no-label';
3412        }
3413
3414        $shell_field_class = "class='" . $field_wrapper_classes . 'grunion-field-' . $trimmed_type . '-wrap ' . esc_attr( $wrap_classes ) . "' ";
3415
3416        /**
3417         * Filter the Contact Form required field text
3418         *
3419         * @module contact-form
3420         *
3421         * @since 3.8.0
3422         *
3423         * @param string $var Required field text. Default is "(required)".
3424         */
3425        $required_field_text = wp_kses_post( apply_filters( 'jetpack_required_field_text', $required_field_text ) );
3426
3427        $block_style       = 'style="' . $this->block_styles . '"';
3428        $has_inset_label   = $this->has_inset_label();
3429        $field             = '';
3430        $field_placeholder = ! empty( $placeholder ) ? "placeholder='" . esc_attr( $placeholder ) . "'" : '';
3431
3432        $context = array(
3433            'fieldId'           => $id,
3434            'fieldType'         => $type,
3435            'fieldLabel'        => $label,
3436            'fieldValue'        => $value,
3437            'fieldPlaceholder'  => $placeholder,
3438            'fieldIsRequired'   => $required,
3439            'fieldErrorMessage' => '',
3440            'fieldExtra'        => $this->get_field_extra( $type, $extra_attrs ),
3441            'formHash'          => $this->form->hash,
3442        );
3443
3444        // Conditional logic is not emitted per field: it cascades, so resolving it needs every
3445        // field's logic and type at once. The form block emits that map once as
3446        // `conditionalLogic`, and this field's wrapper reads its own entry from there.
3447        $interactivity_attrs = ' data-wp-interactive="jetpack/form" ' . wp_interactivity_data_wp_context( $context ) . ' ';
3448
3449        // Hiding a field has to hide whichever element occupies the slot in the row, or the
3450        // field disappears and leaves a hole behind it. For an inset label that is the outer
3451        // wrapper, which is where the width class lives -- the inner div is inside it and
3452        // carries no width of its own.
3453        // data-jp-visibility-root names the element the initial server-side render stamps, so
3454        // the first paint and the runtime always hide the same element. Matching on
3455        // data-jp-field-id instead would stamp the inner div even when the wrapper is the one
3456        // the runtime hides.
3457        $visibility_attrs = " data-jp-visibility-root='" . esc_attr( $id ) . "'"
3458            . ( $this->has_conditional_logic() ? " data-jp-conditional='1'" : '' )
3459            . ' data-wp-class--jetpack-field--conditionally-hidden="state.isFieldHidden"'
3460            // Runs whenever the field's visibility changes, so focus does not fall to <body>
3461            // when the field the visitor is filling in disappears under them.
3462            . ' data-wp-watch--conditional-focus="callbacks.manageConditionalFocus"';
3463
3464        // Fields with an inset label need an extra wrapper to show the error message below the input.
3465        if ( $has_inset_label ) {
3466            $field_width       = $this->get_attribute( 'width' );
3467            $inset_label_class = array( 'contact-form__inset-label-wrap' );
3468
3469            if ( ! empty( $field_width ) ) {
3470                array_push( $inset_label_class, 'grunion-field-width-' . $field_width . '-wrap' );
3471            }
3472
3473            $field              .= "\n<div class='" . implode( ' ', $inset_label_class ) . "' {$interactivity_attrs} {$visibility_attrs} >\n";
3474            $interactivity_attrs = ''; // Reset interactivity attributes for the field wrapper.
3475            $visibility_attrs    = ''; // The outer wrapper owns visibility for this layout.
3476        }
3477
3478        $field .= "\n<div {$block_style} {$interactivity_attrs} {$shell_field_class} data-jp-field-id='" . esc_attr( $id ) . "'{$visibility_attrs} data-wp-init='callbacks.initializeField' data-wp-on--jetpack-form-reset='callbacks.initializeField' >\n"; // new in Jetpack 6.8.0
3479
3480        switch ( $type ) {
3481            case 'email':
3482                $field .= $this->render_email_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $required_indicator );
3483                break;
3484            case 'phone':
3485            case 'telephone':
3486                $field .= $this->render_telephone_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $required_indicator );
3487                break;
3488            case 'url':
3489                $field .= $this->render_url_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $required_indicator );
3490                break;
3491            case 'textarea':
3492                $field .= $this->render_textarea_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $required_indicator );
3493                break;
3494            case 'radio':
3495                $field .= $this->render_radio_field( $id, $label, $value, $field_class, $required, $required_field_text, $required_indicator );
3496                break;
3497            case 'checkbox':
3498                $field .= $this->render_checkbox_field( $id, $label, $value, $field_class, $required, $required_field_text, $required_indicator );
3499                break;
3500            case 'checkbox-multiple':
3501                $field .= $this->render_checkbox_multiple_field( $id, $label, $value, $field_class, $required, $required_field_text, $required_indicator );
3502                break;
3503            case 'select':
3504                $field .= $this->render_select_field( $id, $label, $value, $field_class, $required, $required_field_text, $required_indicator );
3505                break;
3506            case 'date':
3507                $field .= $this->render_date_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $required_indicator );
3508                break;
3509            case 'consent':
3510                $field .= $this->render_consent_field( $id, $field_class );
3511                break;
3512            case 'number':
3513                $field .= $this->render_number_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $extra_attrs, $required_indicator );
3514                break;
3515            case 'slider':
3516                $field .= $this->render_slider_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $extra_attrs, $required_indicator );
3517                break;
3518            case 'file':
3519                $field .= $this->render_file_field( $id, $label, $field_class, $required, $required_field_text, $required_indicator );
3520                break;
3521            case 'rating':
3522                $field .= $this->render_rating_field(
3523                    $id,
3524                    $label,
3525                    $value,
3526                    $field_class,
3527                    $required,
3528                    $required_field_text,
3529                    $required_indicator
3530                );
3531                break;
3532            case 'time':
3533                $field .= $this->render_time_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $required_indicator );
3534                break;
3535            case 'image-select':
3536                $field .= $this->render_image_select_field( $id, $label, $value, $field_class, $required, $required_field_text, $required_indicator );
3537                break;
3538            default: // text field
3539                $field .= $this->render_default_field( $id, $label, $value, $field_class, $required, $required_field_text, $field_placeholder, $type, $required_indicator );
3540                break;
3541        }
3542
3543        $field .= "\t</div>\n";
3544
3545        if ( $has_inset_label ) {
3546            // Description-aware renderers build their markup at the input site,
3547            // where the correct type is known, and defer it here. Field types
3548            // that do not yet do that fall back to the plain error div. Several
3549            // fields render an input whose type differs from the field type
3550            // (date and url render `text`, simple telephone renders `tel`), so
3551            // rebuilding it here from $type would not match the ids the input's
3552            // aria-describedby references.
3553            $field .= $this->deferred_descriptions ?? $this->get_error_div( $id, $type, true );
3554            // Consume it, so a second render cannot replay this one's markup.
3555            $this->deferred_descriptions = null;
3556            // Close the extra wrapper for inset labels.
3557            $field .= "\t</div>\n";
3558        }
3559
3560        return $field;
3561    }
3562
3563    /**
3564     * Returns the extra attributes for the field.
3565     * That are used in field validation.
3566     *
3567     * @param string $type - the field type.
3568     * @param array  $extra_attrs - the extra attributes.
3569     *
3570     * @return string|array The extra attributes.
3571     */
3572    private function get_field_extra( $type, $extra_attrs ) {
3573        if ( 'date' === $type ) {
3574            return $this->get_date_format();
3575        }
3576
3577        if ( 'file' === $type ) {
3578            return array(
3579                'maxFiles'         => $this->get_file_field_max_files(),
3580                'allowedMimeTypes' => self::get_file_field_accepted_mime_types(),
3581            );
3582        }
3583
3584        return $extra_attrs;
3585    }
3586
3587    /**
3588     * Overrides input type (maybe).
3589     *
3590     * @module contact-form
3591     *
3592     * Custom input types, like URL, will rely on browser's implementation to validate
3593     * the value. If the input carries a data-type-override, we allow to override
3594     * the type at render/submit so it can be validated with custom patterns.
3595     * This method will try to match the input's type to a custom data-type-override
3596     * attribute and return it. Defaults to input's type.
3597     *
3598     * @return string The input's type attribute or the overriden type.
3599     */
3600    private function maybe_override_type() {
3601        // Define overridables-to-custom-type, extend as needed.
3602        $overridable_types = array( 'text' => array( 'url' ) );
3603        $type              = $this->get_attribute( 'type' );
3604
3605        if ( ! array_key_exists( $type, $overridable_types ) ) {
3606            return $type;
3607        }
3608
3609        $override_type = $this->get_attribute( 'data-type-override' );
3610
3611        if ( in_array( $override_type, $overridable_types[ $type ], true ) ) {
3612            return $override_type;
3613        }
3614
3615        return $type;
3616    }
3617
3618    /**
3619     * Determines if a form field is valid.
3620     *
3621     * Add checks here to confirm if any given form field
3622     * is configured correctly and thus should be rendered
3623     * on the frontend.
3624     *
3625     * @param string $type - the field type.
3626     *
3627     * @return bool
3628     */
3629    public function is_field_renderable( $type ) {
3630        // Check that radio, select, multiple choice, and image select
3631        // fields have at least one valid option.
3632        if ( $type === 'radio' || $type === 'checkbox-multiple' || $type === 'select' ) {
3633            $options           = (array) $this->get_attribute( 'options' );
3634            $non_empty_options = array_filter(
3635                $options,
3636                function ( $option ) {
3637                    return $option !== '';
3638                }
3639            );
3640            return count( $non_empty_options ) > 0;
3641        }
3642
3643        if ( $type === 'image-select' ) {
3644            $options_data         = (array) $this->get_attribute( 'optionsdata' );
3645            $trimmed_options_data = $this->trim_image_select_options( $options_data );
3646
3647            return ! empty( $trimmed_options_data );
3648        }
3649
3650        return true;
3651    }
3652
3653    /**
3654     * Gets the form style based on its CSS class.
3655     *
3656     * @return string The form style type.
3657     */
3658    private function get_form_style() {
3659        $class_name = $this->form->get_attribute( 'className' );
3660        preg_match( '/is-style-([^\s]+)/i', $class_name, $matches );
3661        return count( $matches ) >= 2 ? $matches[1] : null;
3662    }
3663
3664    /**
3665     * Checks if the form style renders labels inside the field rather than above it.
3666     *
3667     * This is a property of the *style* alone: it stays true for a field type that
3668     * opts out of the inset label itself, because it also governs the extra
3669     * `.contact-form__inset-label-wrap` wrapper, which those fields still need (it
3670     * carries the field width). Use type_has_inset_label() to decide whether a given
3671     * field actually renders an inset label.
3672     *
3673     * @return boolean
3674     */
3675    private function has_inset_label() {
3676        $form_style = $this->get_form_style();
3677
3678        return in_array( $form_style, array( 'outlined', 'animated' ), true );
3679    }
3680
3681    /**
3682     * Checks whether a field type renders an inset label under the current form style.
3683     *
3684     * @param string $type The field type.
3685     *
3686     * @return boolean
3687     */
3688    private function type_has_inset_label( $type ) {
3689        return $this->has_inset_label()
3690            && ! in_array( $type, self::TYPES_WITHOUT_STYLED_LABEL, true )
3691            && ! in_array( $type, self::TYPES_WITHOUT_INSET_LABEL, true );
3692    }
3693
3694    /**
3695     * Return the HTML for the rating (stars/hearts/etc.) field.
3696     *
3697     * This field is purely decorative (spans acting as buttons) and stores the
3698     * selected rating in a hidden input so it is handled by existing form
3699     * validation/submission logic.
3700     *
3701     * @since 0.46.0
3702     *
3703     * @param string $id                 Field ID.
3704     * @param string $label              Field label.
3705     * @param string $value              Current value.
3706     * @param string $class              Additional CSS classes.
3707     * @param bool   $required           Whether field is required.
3708     * @param string $required_field_text Required label text.
3709     * @param bool   $required_indicator Whether to display the required indicator.
3710     * @return string HTML markup.
3711     */
3712    private function render_rating_field( $id, $label, $value, $class, $required, $required_field_text, $required_indicator = true ) {
3713        // Enqueue stylesheet for rating field.
3714        wp_enqueue_style( 'jetpack-form-field-rating-style', plugins_url( '../../dist/blocks/field-rating/style.css', __FILE__ ), array(), Constants::get_constant( 'JETPACK__VERSION' ) );
3715
3716        // Read block attributes needed for rendering.
3717        $max_attr   = $this->get_attribute( 'max' );
3718        $max_rating = is_numeric( $max_attr ) && (int) $max_attr > 0 ? (int) $max_attr : 5;
3719        // The editor caps this, but a hand-edited block or shortcode attribute does not.
3720        $max_rating = min( $max_rating, Feedback_Field::MAX_RATING_ICONS );
3721
3722        $initial_rating = (int) $value ? (int) $value : 0;
3723
3724        $label_html = $this->render_legend_as_label( 'rating', $id, $label, $required, $required_field_text, array(), $required_indicator );
3725
3726        /*
3727         * Determine which icon SVG to use based on the 'iconstyle' attribute.
3728         * Note: attribute name is lowercase due to WordPress shortcode processing
3729         */
3730        $icon_style       = $this->get_attribute( 'iconstyle' );
3731        $has_hearts_style = ( 'hearts' === $icon_style );
3732
3733        // SVG icon definitions - keep in sync with JavaScript icons.js
3734        $star_svg  = '<svg class="jetpack-field-rating__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2 9.19 8.62 2 9.24l5.46 4.73L5.82 21z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path></svg>';
3735        $heart_svg = '<svg class="jetpack-field-rating__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path></svg>';
3736
3737        $icon_svg = $has_hearts_style ? $heart_svg : $star_svg;
3738
3739        $options = '';
3740        for ( $i = 1; $i <= $max_rating; $i++ ) {
3741            $radio_id = $id . '-' . $i;
3742            $options .= sprintf(
3743                '<div class="jetpack-field-rating__option">
3744                    <input
3745                        id="%1$s"
3746                        type="radio"
3747                        name="%2$s"
3748                        value="%3$s/%4$s"
3749                        data-wp-on--change="actions.onFieldChange"
3750                        class="jetpack-field-rating__input visually-hidden"
3751                        %5$s
3752                        %6$s />
3753                    <label for="%1$s" class="jetpack-field-rating__label">
3754                        %7$s
3755                    </label>
3756                </div>',
3757                esc_attr( $radio_id ),         // %1$s: id and label for
3758                esc_attr( $id ),               // %2$s: name
3759                esc_attr( $i ),                // %3$s: value (current rating)
3760                esc_attr( $max_rating ),       // %4$s: value (max rating)
3761                checked( $i, $initial_rating, false ), // %5$s: checked attribute
3762                $required ? 'required' : '',   // %6$s: required attribute
3763                $icon_svg                      // %7$s: icon SVG
3764            );
3765        }
3766
3767        $style_attr = '';
3768
3769        $css_styles = array_filter( array_map( 'trim', explode( ';', $this->field_styles ) ) );
3770
3771        $css_key_value_pairs = array_reduce(
3772            $css_styles,
3773            function ( $pairs, $style ) {
3774                list( $key, $value )   = explode( ':', $style );
3775                $pairs[ trim( $key ) ] = trim( $value );
3776                return $pairs;
3777            },
3778            array()
3779        );
3780
3781        // The rating input overwrites the text color, so we are using a custom logic to set the star color as a CSS variable.
3782        $has_star_color = isset( $css_key_value_pairs['color'] );
3783
3784        if ( $has_star_color ) {
3785            $color_value = $css_key_value_pairs['color'];
3786            $style_attr  = 'style="--jetpack--contact-form--rating-star-color: ' . esc_attr( $color_value ) . ';';
3787            unset( $css_key_value_pairs['color'] );
3788        } else {
3789            // Theme colors are set in the field_classes attribute
3790            $preset_colors = array(
3791                'has-base-color'     => '--wp--preset--color--base',
3792                'has-contrast-color' => '--wp--preset--color--contrast',
3793            );
3794
3795            if ( preg_match( '/has-accent-(\d+)-color/', $this->field_classes, $matches ) ) {
3796                $accent_number = $matches[1];
3797                $preset_colors[ 'has-accent-' . $accent_number . '-color' ] = '--wp--preset--color--accent-' . $accent_number;
3798            }
3799
3800            foreach ( $preset_colors as $class => $css_var ) {
3801                if ( strpos( $this->field_classes, $class ) !== false ) {
3802                    $style_attr = 'style="--jetpack--contact-form--rating-star-color: var(' . esc_attr( $css_var ) . ');';
3803
3804                    break;
3805                }
3806            }
3807        }
3808
3809        $remaining_styles = array_map(
3810            function ( $key, $value ) {
3811                return $key . ': ' . $value;
3812            },
3813            array_keys( $css_key_value_pairs ),
3814            array_values( $css_key_value_pairs )
3815        );
3816
3817        $style_attr .= ' ' . implode( ';', $remaining_styles ) . '"';
3818
3819        return sprintf(
3820            '<fieldset id="%4$s-label" class="jetpack-field-multiple__fieldset jetpack-field-rating" %1$s%6$s>
3821                %5$s
3822                <div class="jetpack-field-rating__options %3$s">%2$s</div>
3823            </fieldset>',
3824            $style_attr,
3825            $options,
3826            esc_attr( $this->field_classes ),
3827            esc_attr( $id ),
3828            $label_html,
3829            $this->get_hidden_label_aria_label_attr()
3830        ) . $this->get_error_div( $id, 'rating' );
3831    }
3832
3833    /**
3834     * Return the HTML for the slider field.
3835     *
3836     * @since 5.1.0
3837     *
3838     * @param int    $id The field ID.
3839     * @param string $label The field label.
3840     * @param string $value The field value.
3841     * @param string $class The field class.
3842     * @param bool   $required Whether the field is required.
3843     * @param string $required_field_text The required field text.
3844     * @param string $placeholder The field placeholder.
3845     * @param array  $extra_attrs Extra attributes (e.g., min, max).
3846     * @param bool   $required_indicator Whether to display the required indicator.
3847     *
3848     * @return string HTML for the slider field.
3849     */
3850    public function render_slider_field( $id, $label, $value, $class, $required, $required_field_text, $placeholder, $extra_attrs = array(), $required_indicator = true ) {
3851        $this->enqueue_slider_field_assets();
3852        $this->set_invalid_message( 'slider', __( 'Please select a valid value', 'jetpack-forms' ) );
3853        if ( isset( $extra_attrs['min'] ) ) {
3854            // translators: %d is the minimum value.
3855            $this->set_invalid_message( 'min_slider', __( 'Please select a value that is no less than %d.', 'jetpack-forms' ) );
3856        }
3857        if ( isset( $extra_attrs['max'] ) ) {
3858            // translators: %d is the maximum value.
3859            $this->set_invalid_message( 'max_slider', __( 'Please select a value that is no more than %d.', 'jetpack-forms' ) );
3860        }
3861        $min            = $extra_attrs['min'] ?? 0;
3862        $max            = $extra_attrs['max'] ?? 100;
3863        $starting_value = $extra_attrs['default'] ?? 0;
3864        $step           = $extra_attrs['step'] ?? 1;
3865        $current_value  = ( $value !== '' && $value !== null ) ? $value : $starting_value;
3866        $min_text_label = $extra_attrs['minLabel'] ?? '';
3867        $max_text_label = $extra_attrs['maxLabel'] ?? '';
3868
3869        // $always_render must be true: 'slider' is excluded from inset labels in
3870        // render_label(), so without it the label would be dropped entirely when the
3871        // form uses the Outlined or Animated style.
3872        $field = $this->render_label( 'slider', $id, $label, $required, $required_field_text, array(), true, $required_indicator );
3873
3874        ob_start();
3875        ?>
3876        <div class="jetpack-field-slider__input-row <?php echo esc_attr( $this->field_classes ); ?>"
3877            data-wp-context='
3878            <?php
3879            echo esc_attr(
3880                wp_json_encode(
3881                    array(
3882                        'min'     => $min,
3883                        'max'     => $max,
3884                        'default' => $starting_value,
3885                        'step'    => $step,
3886                    ),
3887                    JSON_HEX_AMP | JSON_UNESCAPED_SLASHES
3888                )
3889            );
3890            ?>
3891            '>
3892            <span class="jetpack-field-slider__min-label"><?php echo esc_html( $min ); ?></span>
3893            <div class="jetpack-field-slider__input-container">
3894                <input
3895                    type="range"
3896                    name="<?php echo esc_attr( $id ); ?>"
3897                    id="<?php echo esc_attr( $id ); ?>"
3898                    value="<?php echo esc_attr( $current_value ); ?>"
3899                    min="<?php echo esc_attr( $min ); ?>"
3900                    max="<?php echo esc_attr( $max ); ?>"
3901                    step="<?php echo esc_attr( $step ); ?>"
3902                    class="<?php echo esc_attr( trim( $class . ' jetpack-field-slider__range' ) ); ?>"
3903                    placeholder="<?php echo esc_attr( $placeholder ); ?>"
3904                    <?php
3905                    if ( $required ) :
3906                        ?>
3907                    required<?php endif; ?>
3908                    data-wp-bind--value="state.getSliderValue"
3909                    data-wp-on--input="actions.onSliderChange"
3910                    data-wp-bind--aria-invalid="state.fieldAriaInvalid"
3911                    aria-describedby="<?php echo esc_attr( $this->get_described_by( $id, 'slider' ) ); ?>"
3912                    <?php echo $this->get_hidden_label_aria_label_attr(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attribute value is escaped in the helper. ?>
3913                />
3914                <div
3915                    class="jetpack-field-slider__value-indicator"
3916                    data-wp-text="state.getSliderValue"
3917                    data-wp-style--left="state.getSliderPosition"
3918                ><?php echo esc_html( $current_value ); ?></div>
3919            </div>
3920            <span class="jetpack-field-slider__max-label"><?php echo esc_html( $max ); ?></span>
3921        </div>
3922        <?php if ( '' !== $min_text_label || '' !== $max_text_label ) : ?>
3923            <div class="jetpack-field-slider__text-labels <?php echo esc_attr( $this->field_classes ); ?>" aria-hidden="true">
3924                <span class="jetpack-field-slider__min-text-label"><?php echo esc_html( $min_text_label ); ?></span>
3925                <span class="jetpack-field-slider__max-text-label"><?php echo esc_html( $max_text_label ); ?></span>
3926            </div>
3927        <?php endif; ?>
3928        <?php
3929        $field .= ob_get_clean();
3930        return $field . $this->get_field_descriptions( $id, 'slider' );
3931    }
3932
3933    /**
3934     * Enqueues scripts and styles needed for the slider field.
3935     *
3936     * @since 5.1.0
3937     *
3938     * @return void
3939     */
3940    private function enqueue_slider_field_assets() {
3941        $version = defined( 'JETPACK__VERSION' ) ? \JETPACK__VERSION : '0.1';
3942
3943        \wp_enqueue_style(
3944            'jetpack-form-slider-field',
3945            plugins_url( '../../dist/blocks/input-range/style.css', __FILE__ ),
3946            array(),
3947            $version
3948        );
3949
3950        \wp_enqueue_script_module(
3951            'jetpack-form-slider-field',
3952            plugins_url( '../../dist/modules/slider-field/view.js', __FILE__ ),
3953            array( '@wordpress/interactivity' ),
3954            $version
3955        );
3956    }
3957
3958    /**
3959     * Gets an array of translatable country names indexed by their two-letter country codes.
3960     *
3961     * @since 6.2.1
3962     *
3963     * @return array Array of country names with two-letter country codes as keys.
3964     */
3965    public function get_translatable_countries() {
3966        return array(
3967            'AF' => __( 'Afghanistan', 'jetpack-forms' ),
3968            'AL' => __( 'Albania', 'jetpack-forms' ),
3969            'DZ' => __( 'Algeria', 'jetpack-forms' ),
3970            'AS' => __( 'American Samoa', 'jetpack-forms' ),
3971            'AD' => __( 'Andorra', 'jetpack-forms' ),
3972            'AO' => __( 'Angola', 'jetpack-forms' ),
3973            'AI' => __( 'Anguilla', 'jetpack-forms' ),
3974            'AG' => __( 'Antigua and Barbuda', 'jetpack-forms' ),
3975            'AR' => __( 'Argentina', 'jetpack-forms' ),
3976            'AM' => __( 'Armenia', 'jetpack-forms' ),
3977            'AW' => __( 'Aruba', 'jetpack-forms' ),
3978            'AU' => __( 'Australia', 'jetpack-forms' ),
3979            'AT' => __( 'Austria', 'jetpack-forms' ),
3980            'AZ' => __( 'Azerbaijan', 'jetpack-forms' ),
3981            'BS' => __( 'Bahamas', 'jetpack-forms' ),
3982            'BH' => __( 'Bahrain', 'jetpack-forms' ),
3983            'BD' => __( 'Bangladesh', 'jetpack-forms' ),
3984            'BB' => __( 'Barbados', 'jetpack-forms' ),
3985            'BY' => __( 'Belarus', 'jetpack-forms' ),
3986            'BE' => __( 'Belgium', 'jetpack-forms' ),
3987            'BZ' => __( 'Belize', 'jetpack-forms' ),
3988            'BJ' => __( 'Benin', 'jetpack-forms' ),
3989            'BM' => __( 'Bermuda', 'jetpack-forms' ),
3990            'BT' => __( 'Bhutan', 'jetpack-forms' ),
3991            'BO' => __( 'Bolivia', 'jetpack-forms' ),
3992            'BA' => __( 'Bosnia and Herzegovina', 'jetpack-forms' ),
3993            'BW' => __( 'Botswana', 'jetpack-forms' ),
3994            'BR' => __( 'Brazil', 'jetpack-forms' ),
3995            'IO' => __( 'British Indian Ocean Territory', 'jetpack-forms' ),
3996            'VG' => __( 'British Virgin Islands', 'jetpack-forms' ),
3997            'BN' => __( 'Brunei', 'jetpack-forms' ),
3998            'BG' => __( 'Bulgaria', 'jetpack-forms' ),
3999            'BF' => __( 'Burkina Faso', 'jetpack-forms' ),
4000            'BI' => __( 'Burundi', 'jetpack-forms' ),
4001            'KH' => __( 'Cambodia', 'jetpack-forms' ),
4002            'CM' => __( 'Cameroon', 'jetpack-forms' ),
4003            'CA' => __( 'Canada', 'jetpack-forms' ),
4004            'CV' => __( 'Cape Verde', 'jetpack-forms' ),
4005            'KY' => __( 'Cayman Islands', 'jetpack-forms' ),
4006            'CF' => __( 'Central African Republic', 'jetpack-forms' ),
4007            'TD' => __( 'Chad', 'jetpack-forms' ),
4008            'CL' => __( 'Chile', 'jetpack-forms' ),
4009            'CN' => __( 'China', 'jetpack-forms' ),
4010            'CX' => __( 'Christmas Island', 'jetpack-forms' ),
4011            'CC' => __( 'Cocos (Keeling) Islands', 'jetpack-forms' ),
4012            'CO' => __( 'Colombia', 'jetpack-forms' ),
4013            'KM' => __( 'Comoros', 'jetpack-forms' ),
4014            'CG' => __( 'Congo - Brazzaville', 'jetpack-forms' ),
4015            'CD' => __( 'Congo - Kinshasa', 'jetpack-forms' ),
4016            'CK' => __( 'Cook Islands', 'jetpack-forms' ),
4017            'CR' => __( 'Costa Rica', 'jetpack-forms' ),
4018            'HR' => __( 'Croatia', 'jetpack-forms' ),
4019            'CU' => __( 'Cuba', 'jetpack-forms' ),
4020            'CY' => __( 'Cyprus', 'jetpack-forms' ),
4021            'CZ' => __( 'Czech Republic', 'jetpack-forms' ),
4022            'CI' => __( "Côte d'Ivoire", 'jetpack-forms' ),
4023            'DK' => __( 'Denmark', 'jetpack-forms' ),
4024            'DJ' => __( 'Djibouti', 'jetpack-forms' ),
4025            'DM' => __( 'Dominica', 'jetpack-forms' ),
4026            'DO' => __( 'Dominican Republic', 'jetpack-forms' ),
4027            'EC' => __( 'Ecuador', 'jetpack-forms' ),
4028            'EG' => __( 'Egypt', 'jetpack-forms' ),
4029            'SV' => __( 'El Salvador', 'jetpack-forms' ),
4030            'GQ' => __( 'Equatorial Guinea', 'jetpack-forms' ),
4031            'ER' => __( 'Eritrea', 'jetpack-forms' ),
4032            'EE' => __( 'Estonia', 'jetpack-forms' ),
4033            'SZ' => __( 'Eswatini', 'jetpack-forms' ),
4034            'ET' => __( 'Ethiopia', 'jetpack-forms' ),
4035            'FK' => __( 'Falkland Islands', 'jetpack-forms' ),
4036            'FO' => __( 'Faroe Islands', 'jetpack-forms' ),
4037            'FJ' => __( 'Fiji', 'jetpack-forms' ),
4038            'FI' => __( 'Finland', 'jetpack-forms' ),
4039            'FR' => __( 'France', 'jetpack-forms' ),
4040            'GF' => __( 'French Guiana', 'jetpack-forms' ),
4041            'PF' => __( 'French Polynesia', 'jetpack-forms' ),
4042            'GA' => __( 'Gabon', 'jetpack-forms' ),
4043            'GM' => __( 'Gambia', 'jetpack-forms' ),
4044            'GE' => __( 'Georgia', 'jetpack-forms' ),
4045            'DE' => __( 'Germany', 'jetpack-forms' ),
4046            'GH' => __( 'Ghana', 'jetpack-forms' ),
4047            'GI' => __( 'Gibraltar', 'jetpack-forms' ),
4048            'GR' => __( 'Greece', 'jetpack-forms' ),
4049            'GL' => __( 'Greenland', 'jetpack-forms' ),
4050            'GD' => __( 'Grenada', 'jetpack-forms' ),
4051            'GP' => __( 'Guadeloupe', 'jetpack-forms' ),
4052            'GU' => __( 'Guam', 'jetpack-forms' ),
4053            'GT' => __( 'Guatemala', 'jetpack-forms' ),
4054            'GG' => __( 'Guernsey', 'jetpack-forms' ),
4055            'GN' => __( 'Guinea', 'jetpack-forms' ),
4056            'GW' => __( 'Guinea-Bissau', 'jetpack-forms' ),
4057            'GY' => __( 'Guyana', 'jetpack-forms' ),
4058            'HT' => __( 'Haiti', 'jetpack-forms' ),
4059            'HN' => __( 'Honduras', 'jetpack-forms' ),
4060            'HK' => __( 'Hong Kong', 'jetpack-forms' ),
4061            'HU' => __( 'Hungary', 'jetpack-forms' ),
4062            'IS' => __( 'Iceland', 'jetpack-forms' ),
4063            'IN' => __( 'India', 'jetpack-forms' ),
4064            'ID' => __( 'Indonesia', 'jetpack-forms' ),
4065            'IR' => __( 'Iran', 'jetpack-forms' ),
4066            'IQ' => __( 'Iraq', 'jetpack-forms' ),
4067            'IE' => __( 'Ireland', 'jetpack-forms' ),
4068            'IM' => __( 'Isle of Man', 'jetpack-forms' ),
4069            'IL' => __( 'Israel', 'jetpack-forms' ),
4070            'IT' => __( 'Italy', 'jetpack-forms' ),
4071            'JM' => __( 'Jamaica', 'jetpack-forms' ),
4072            'JP' => __( 'Japan', 'jetpack-forms' ),
4073            'JE' => __( 'Jersey', 'jetpack-forms' ),
4074            'JO' => __( 'Jordan', 'jetpack-forms' ),
4075            'KZ' => __( 'Kazakhstan', 'jetpack-forms' ),
4076            'KE' => __( 'Kenya', 'jetpack-forms' ),
4077            'KI' => __( 'Kiribati', 'jetpack-forms' ),
4078            'XK' => __( 'Kosovo', 'jetpack-forms' ),
4079            'KW' => __( 'Kuwait', 'jetpack-forms' ),
4080            'KG' => __( 'Kyrgyzstan', 'jetpack-forms' ),
4081            'LA' => __( 'Laos', 'jetpack-forms' ),
4082            'LV' => __( 'Latvia', 'jetpack-forms' ),
4083            'LB' => __( 'Lebanon', 'jetpack-forms' ),
4084            'LS' => __( 'Lesotho', 'jetpack-forms' ),
4085            'LR' => __( 'Liberia', 'jetpack-forms' ),
4086            'LY' => __( 'Libya', 'jetpack-forms' ),
4087            'LI' => __( 'Liechtenstein', 'jetpack-forms' ),
4088            'LT' => __( 'Lithuania', 'jetpack-forms' ),
4089            'LU' => __( 'Luxembourg', 'jetpack-forms' ),
4090            'MO' => __( 'Macao', 'jetpack-forms' ),
4091            'MG' => __( 'Madagascar', 'jetpack-forms' ),
4092            'MW' => __( 'Malawi', 'jetpack-forms' ),
4093            'MY' => __( 'Malaysia', 'jetpack-forms' ),
4094            'MV' => __( 'Maldives', 'jetpack-forms' ),
4095            'ML' => __( 'Mali', 'jetpack-forms' ),
4096            'MT' => __( 'Malta', 'jetpack-forms' ),
4097            'MH' => __( 'Marshall Islands', 'jetpack-forms' ),
4098            'MQ' => __( 'Martinique', 'jetpack-forms' ),
4099            'MR' => __( 'Mauritania', 'jetpack-forms' ),
4100            'MU' => __( 'Mauritius', 'jetpack-forms' ),
4101            'YT' => __( 'Mayotte', 'jetpack-forms' ),
4102            'MX' => __( 'Mexico', 'jetpack-forms' ),
4103            'FM' => __( 'Micronesia', 'jetpack-forms' ),
4104            'MD' => __( 'Moldova', 'jetpack-forms' ),
4105            'MC' => __( 'Monaco', 'jetpack-forms' ),
4106            'MN' => __( 'Mongolia', 'jetpack-forms' ),
4107            'ME' => __( 'Montenegro', 'jetpack-forms' ),
4108            'MS' => __( 'Montserrat', 'jetpack-forms' ),
4109            'MA' => __( 'Morocco', 'jetpack-forms' ),
4110            'MZ' => __( 'Mozambique', 'jetpack-forms' ),
4111            'MM' => __( 'Myanmar', 'jetpack-forms' ),
4112            'NA' => __( 'Namibia', 'jetpack-forms' ),
4113            'NR' => __( 'Nauru', 'jetpack-forms' ),
4114            'NP' => __( 'Nepal', 'jetpack-forms' ),
4115            'NL' => __( 'Netherlands', 'jetpack-forms' ),
4116            'NC' => __( 'New Caledonia', 'jetpack-forms' ),
4117            'NZ' => __( 'New Zealand', 'jetpack-forms' ),
4118            'NI' => __( 'Nicaragua', 'jetpack-forms' ),
4119            'NE' => __( 'Niger', 'jetpack-forms' ),
4120            'NG' => __( 'Nigeria', 'jetpack-forms' ),
4121            'NU' => __( 'Niue', 'jetpack-forms' ),
4122            'NF' => __( 'Norfolk Island', 'jetpack-forms' ),
4123            'KP' => __( 'North Korea', 'jetpack-forms' ),
4124            'MK' => __( 'North Macedonia', 'jetpack-forms' ),
4125            'MP' => __( 'Northern Mariana Islands', 'jetpack-forms' ),
4126            'NO' => __( 'Norway', 'jetpack-forms' ),
4127            'OM' => __( 'Oman', 'jetpack-forms' ),
4128            'PK' => __( 'Pakistan', 'jetpack-forms' ),
4129            'PW' => __( 'Palau', 'jetpack-forms' ),
4130            'PS' => __( 'Palestine', 'jetpack-forms' ),
4131            'PA' => __( 'Panama', 'jetpack-forms' ),
4132            'PG' => __( 'Papua New Guinea', 'jetpack-forms' ),
4133            'PY' => __( 'Paraguay', 'jetpack-forms' ),
4134            'PE' => __( 'Peru', 'jetpack-forms' ),
4135            'PH' => __( 'Philippines', 'jetpack-forms' ),
4136            'PN' => __( 'Pitcairn Islands', 'jetpack-forms' ),
4137            'PL' => __( 'Poland', 'jetpack-forms' ),
4138            'PT' => __( 'Portugal', 'jetpack-forms' ),
4139            'PR' => __( 'Puerto Rico', 'jetpack-forms' ),
4140            'QA' => __( 'Qatar', 'jetpack-forms' ),
4141            'RO' => __( 'Romania', 'jetpack-forms' ),
4142            'RU' => __( 'Russia', 'jetpack-forms' ),
4143            'RW' => __( 'Rwanda', 'jetpack-forms' ),
4144            'RE' => __( 'Réunion', 'jetpack-forms' ),
4145            'BL' => __( 'Saint Barthélemy', 'jetpack-forms' ),
4146            'SH' => __( 'Saint Helena', 'jetpack-forms' ),
4147            'KN' => __( 'Saint Kitts and Nevis', 'jetpack-forms' ),
4148            'LC' => __( 'Saint Lucia', 'jetpack-forms' ),
4149            'MF' => __( 'Saint Martin', 'jetpack-forms' ),
4150            'PM' => __( 'Saint Pierre and Miquelon', 'jetpack-forms' ),
4151            'VC' => __( 'Saint Vincent and the Grenadines', 'jetpack-forms' ),
4152            'WS' => __( 'Samoa', 'jetpack-forms' ),
4153            'SM' => __( 'San Marino', 'jetpack-forms' ),
4154            'SA' => __( 'Saudi Arabia', 'jetpack-forms' ),
4155            'SN' => __( 'Senegal', 'jetpack-forms' ),
4156            'RS' => __( 'Serbia', 'jetpack-forms' ),
4157            'SC' => __( 'Seychelles', 'jetpack-forms' ),
4158            'SL' => __( 'Sierra Leone', 'jetpack-forms' ),
4159            'SG' => __( 'Singapore', 'jetpack-forms' ),
4160            'SK' => __( 'Slovakia', 'jetpack-forms' ),
4161            'SI' => __( 'Slovenia', 'jetpack-forms' ),
4162            'SB' => __( 'Solomon Islands', 'jetpack-forms' ),
4163            'SO' => __( 'Somalia', 'jetpack-forms' ),
4164            'ZA' => __( 'South Africa', 'jetpack-forms' ),
4165            'GS' => __( 'South Georgia and the South Sandwich Islands', 'jetpack-forms' ),
4166            'KR' => __( 'South Korea', 'jetpack-forms' ),
4167            'ES' => __( 'Spain', 'jetpack-forms' ),
4168            'LK' => __( 'Sri Lanka', 'jetpack-forms' ),
4169            'SD' => __( 'Sudan', 'jetpack-forms' ),
4170            'SR' => __( 'Suriname', 'jetpack-forms' ),
4171            'SJ' => __( 'Svalbard and Jan Mayen', 'jetpack-forms' ),
4172            'SE' => __( 'Sweden', 'jetpack-forms' ),
4173            'CH' => __( 'Switzerland', 'jetpack-forms' ),
4174            'SY' => __( 'Syria', 'jetpack-forms' ),
4175            'ST' => __( 'São Tomé and Príncipe', 'jetpack-forms' ),
4176            'TW' => __( 'Taiwan', 'jetpack-forms' ),
4177            'TJ' => __( 'Tajikistan', 'jetpack-forms' ),
4178            'TZ' => __( 'Tanzania', 'jetpack-forms' ),
4179            'TH' => __( 'Thailand', 'jetpack-forms' ),
4180            'TL' => __( 'Timor-Leste', 'jetpack-forms' ),
4181            'TG' => __( 'Togo', 'jetpack-forms' ),
4182            'TK' => __( 'Tokelau', 'jetpack-forms' ),
4183            'TO' => __( 'Tonga', 'jetpack-forms' ),
4184            'TT' => __( 'Trinidad and Tobago', 'jetpack-forms' ),
4185            'TN' => __( 'Tunisia', 'jetpack-forms' ),
4186            'TR' => __( 'Turkey', 'jetpack-forms' ),
4187            'TM' => __( 'Turkmenistan', 'jetpack-forms' ),
4188            'TC' => __( 'Turks and Caicos Islands', 'jetpack-forms' ),
4189            'TV' => __( 'Tuvalu', 'jetpack-forms' ),
4190            'VI' => __( 'U.S. Virgin Islands', 'jetpack-forms' ),
4191            'UG' => __( 'Uganda', 'jetpack-forms' ),
4192            'UA' => __( 'Ukraine', 'jetpack-forms' ),
4193            'AE' => __( 'United Arab Emirates', 'jetpack-forms' ),
4194            'GB' => __( 'United Kingdom', 'jetpack-forms' ),
4195            'US' => __( 'United States', 'jetpack-forms' ),
4196            'UY' => __( 'Uruguay', 'jetpack-forms' ),
4197            'UZ' => __( 'Uzbekistan', 'jetpack-forms' ),
4198            'VU' => __( 'Vanuatu', 'jetpack-forms' ),
4199            'VA' => __( 'Vatican City', 'jetpack-forms' ),
4200            'VE' => __( 'Venezuela', 'jetpack-forms' ),
4201            'VN' => __( 'Vietnam', 'jetpack-forms' ),
4202            'WF' => __( 'Wallis and Futuna', 'jetpack-forms' ),
4203            'YE' => __( 'Yemen', 'jetpack-forms' ),
4204            'ZM' => __( 'Zambia', 'jetpack-forms' ),
4205            'ZW' => __( 'Zimbabwe', 'jetpack-forms' ),
4206        );
4207    }
4208
4209    /**
4210     * Enqueues scripts and styles needed for the slider field.
4211     *
4212     * @since 6.2.1
4213     *
4214     * @return void
4215     */
4216    private function enqueue_phone_field_assets() {
4217        $version = defined( 'JETPACK__VERSION' ) ? \JETPACK__VERSION : '0.1';
4218
4219        // extra cache busting strategy for view.js, seems they are left out of cache clearing on deploys
4220        $asset_file = plugin_dir_path( __FILE__ ) . '../../dist/modules/field-phone/view.asset.php';
4221        $asset      = file_exists( $asset_file ) ? require $asset_file : null;
4222        $version   .= $asset['version'] ?? '';
4223
4224        // combobox styles
4225        \wp_enqueue_style(
4226            'jetpack-form-combobox',
4227            plugins_url( '../../dist/contact-form/css/combobox.css', __FILE__ ),
4228            array(),
4229            $version
4230        );
4231
4232        \wp_enqueue_style(
4233            'jetpack-form-phone-field',
4234            plugins_url( '../../dist/contact-form/css/phone-field.css', __FILE__ ),
4235            array(),
4236            $version
4237        );
4238
4239        \wp_enqueue_script_module(
4240            'jetpack-form-phone-field',
4241            plugins_url( '../../dist/modules/field-phone/view.js', __FILE__ ),
4242            array( '@wordpress/interactivity', 'jp-forms-view' ),
4243            $version
4244        );
4245    }
4246
4247    /**
4248     * Trims the image select options from the end of the array if they are empty.
4249     *
4250     * @param array $options The options to trim.
4251     *
4252     * @return array The trimmed options array.
4253     */
4254    private function trim_image_select_options( $options ) {
4255        if ( empty( $options ) ) {
4256            return $options;
4257        }
4258
4259        // Work backwards through the array to find the last valid option
4260        $last_valid_index = -1;
4261
4262        for ( $i = count( $options ) - 1; $i >= 0; $i-- ) {
4263            $option = $options[ $i ];
4264
4265            // Check if option has a label
4266            $has_label = ! empty( $option['label'] );
4267
4268            // Check if option has an image with src
4269            $has_image = false;
4270
4271            if ( isset( $option['image']['innerHTML'] ) ) {
4272                // Extract src from innerHTML using regex
4273                preg_match( '/src="([^"]*)"/', $option['image']['innerHTML'], $matches );
4274                $has_image = ! empty( $matches[1] );
4275            }
4276
4277            // If this option has either a label or an image, it's valid
4278            if ( $has_label || $has_image ) {
4279                $last_valid_index = $i;
4280                break;
4281            }
4282        }
4283
4284        return array_slice( $options, 0, $last_valid_index + 1 );
4285    }
4286}