felixarntz/plugin-lib

View on GitHub
src/fields/field.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php
/**
 * Field base class
 *
 * @package Leaves_And_Love\Plugin_Lib
 * @since 1.0.0
 */

namespace Leaves_And_Love\Plugin_Lib\Fields;

use WP_Error;

if ( ! class_exists( 'Leaves_And_Love\Plugin_Lib\Fields\Field' ) ) :

    /**
     * Base class for a field
     *
     * @since 1.0.0
     */
    abstract class Field {
        /**
         * Field manager instance.
         *
         * @since 1.0.0
         * @var Field_Manager
         */
        protected $manager = null;

        /**
         * Dependency resolver for this field, if applicable.
         *
         * @since 1.0.0
         * @var Dependency_Resolver|null
         */
        protected $dependency_resolver = null;

        /**
         * Field type identifier.
         *
         * @since 1.0.0
         * @var string
         */
        protected $slug = '';

        /**
         * Field identifier. Used to create the id and name attributes.
         *
         * @since 1.0.0
         * @var string
         */
        protected $id = '';

        /**
         * Section identifier this field belongs to.
         *
         * @since 1.0.0
         * @var string
         */
        protected $section = '';

        /**
         * Field label.
         *
         * @since 1.0.0
         * @var string
         */
        protected $label = '';

        /**
         * Field description.
         *
         * @since 1.0.0
         * @var string
         */
        protected $description = '';

        /**
         * Default value of the field.
         *
         * @since 1.0.0
         * @var mixed
         */
        protected $default = null;

        /**
         * Whether to display this field.
         *
         * @since 1.0.0
         * @var bool
         */
        protected $display = true;

        /**
         * Whether this is a repeatable field.
         *
         * @since 1.0.0
         * @var bool
         */
        protected $repeatable = false;

        /**
         * Array of CSS classes for the field input.
         *
         * @since 1.0.0
         * @var array
         */
        protected $input_classes = array();

        /**
         * Array of CSS classes for the field label.
         *
         * @since 1.0.0
         * @var array
         */
        protected $label_classes = array();

        /**
         * Array of CSS classes for the field wrap.
         *
         * @since 1.0.0
         * @var array
         */
        protected $wrap_classes = array();

        /**
         * Label mode for this field's label.
         *
         * Accepts values 'explicit', 'implicit', 'no_assoc', 'aria_hidden' and 'skip'.
         *
         * @since 1.0.0
         * @var string
         */
        protected $label_mode = 'explicit';

        /**
         * Array of additional input attributes as `$key => $value` pairs.
         *
         * @since 1.0.0
         * @var array
         */
        protected $input_attrs = array();

        /**
         * Custom validation callback.
         *
         * @since 1.0.0
         * @var callable|null
         */
        protected $validate = null;

        /**
         * Callback or string for output to print before the field.
         *
         * @since 1.0.0
         * @var callable|string|null
         */
        protected $before = null;

        /**
         * Callback or string for output to print after the field.
         *
         * @since 1.0.0
         * @var callable|string|null
         */
        protected $after = null;

        /**
         * Backbone view class name to use for this field.
         *
         * @since 1.0.0
         * @var string
         */
        protected $backbone_view = 'FieldView';

        /**
         * Internal index counter for repeatable fields.
         *
         * @since 1.0.0
         * @var int|null
         */
        protected $index = null;

        /**
         * Constructor.
         *
         * @since 1.0.0
         *
         * @param Field_Manager $manager Field manager instance.
         * @param string        $id      Field identifier.
         * @param array         $args    {
         *     Optional. Field arguments. Anything you pass in addition to the default supported arguments
         *     will be used as an attribute on the input. Default empty array.
         *
         *     @type string          $section       Section identifier this field belongs to. Default empty.
         *     @type string          $label         Field label. Default empty.
         *     @type string          $description   Field description. Default empty.
         *     @type mixed           $default       Default value for the field. Default null.
         *     @type bool|int        $repeatable    Whether this should be a repeatable field. An integer can also
         *                                          be passed to set the limit of repetitions allowed. Default false.
         *     @type array           $input_classes Array of CSS classes for the field input. Default empty array.
         *     @type array           $label_classes Array of CSS classes for the field label. Default empty array.
         *     @type array           $wrap_classes  Array of CSS classes for the field wrap. Default empty array.
         *     @type callable        $validate      Custom validation callback. Will be executed after doing the regular
         *                                          validation if no errors occurred in the meantime. Default none.
         *     @type callable|string $before        Callback or string that should be used to generate output that will
         *                                          be printed before the field. Default none.
         *     @type callable|string $after         Callback or string that should be used to generate output that will
         *                                          be printed after the field. Default none.
         * }
         */
        public function __construct( $manager, $id, $args = array() ) {
            $this->manager = $manager;
            $this->id      = $id;

            $forbidden_keys = $this->get_forbidden_keys();

            $dependencies = null;
            if ( isset( $args['dependencies'] ) ) {
                $dependencies = $args['dependencies'];
                unset( $args['dependencies'] );
            }

            foreach ( $args as $key => $value ) {
                if ( in_array( $key, $forbidden_keys, true ) ) {
                    continue;
                }

                if ( property_exists( $this, $key ) ) {
                    $this->$key = $value;
                } else {
                    $this->input_attrs[ $key ] = $value;
                }
            }

            $this->label_classes[] = 'plugin-lib-label';
            $this->input_classes[] = 'plugin-lib-control';
            $this->input_classes[] = 'plugin-lib-' . $this->slug . '-control';

            if ( ! empty( $this->description ) ) {
                $this->input_attrs['aria-describedby'] = $this->get_id_attribute() . '-description';
            }

            /* Repeatable is not allowed when the $label_mode is not 'explicit'. */
            if ( 'explicit' !== $this->label_mode ) {
                $this->repeatable = false;
            } elseif ( $this->is_repeatable() ) {
                $this->label_mode = 'no_assoc';
            }

            if ( null !== $dependencies ) {
                $this->dependency_resolver = new Dependency_Resolver( $dependencies, $this, $this->manager );
            }
        }

        /**
         * Magic isset-er.
         *
         * Checks whether a property is set.
         *
         * @since 1.0.0
         *
         * @param string $property Property to check for.
         * @return bool True if the property is set, false otherwise.
         */
        public function __isset( $property ) {
            if ( 'manager' === $property ) {
                return false;
            }

            return isset( $this->$property );
        }

        /**
         * Magic getter.
         *
         * Returns a property value.
         *
         * @since 1.0.0
         *
         * @param string $property Property to get.
         * @return mixed Property value, or null if property is not set.
         */
        public function __get( $property ) {
            if ( 'manager' === $property ) {
                return null;
            }

            if ( ! isset( $this->$property ) ) {
                return null;
            }

            if ( 'repeatable' !== $property && ! in_array( $property, $this->get_forbidden_keys(), true ) ) {
                $this->maybe_resolve_dependencies();
            }

            return $this->$property;
        }

        /**
         * Enqueues the necessary assets for the field.
         *
         * @since 1.0.0
         *
         * @return array Array where the first element is an array of script handles and the second element
         *               is an associative array of data to pass to the main script.
         */
        public function enqueue() {
            $this->maybe_resolve_dependencies();

            return array( array(), array() );
        }

        /**
         * Renders the field's label.
         *
         * @since 1.0.0
         */
        public function render_label() {
            $this->maybe_resolve_dependencies();

            echo '<div id="' . esc_attr( $this->get_id_attribute() . '-label-wrap' ) . '" class="label-wrap">';

            if ( ! empty( $this->label ) && 'skip' !== $this->label_mode ) {
                if ( in_array( $this->label_mode, array( 'no_assoc', 'aria_hidden' ), true ) ) {
                    ?>
                    <span<?php echo $this->get_label_attrs(); /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>>
                        <?php echo wp_kses_data( $this->label ); ?>
                        <?php if ( ! $this->is_repeatable() && isset( $this->input_attrs['required'] ) && $this->input_attrs['required'] ) : ?>
                            <?php echo $this->manager->get_field_required_markup(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
                        <?php endif; ?>
                    </span>
                    <?php
                } else {
                    ?>
                    <label<?php echo $this->get_label_attrs(); /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>>
                        <?php echo wp_kses_data( $this->label ); ?>
                        <?php if ( ! $this->is_repeatable() && isset( $this->input_attrs['required'] ) && $this->input_attrs['required'] ) : ?>
                            <?php echo $this->manager->get_field_required_markup(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
                        <?php endif; ?>
                    </label>
                    <?php
                }
            }

            echo '</div>';
        }

        /**
         * Renders the field's main content including the input.
         *
         * @since 1.0.0
         *
         * @param mixed $current_value Current value of the field.
         */
        public function render_content( $current_value ) {
            $this->maybe_resolve_dependencies();

            echo '<div id="' . esc_attr( $this->get_id_attribute() . '-content-wrap' ) . '" class="content-wrap">';

            if ( ! empty( $this->before ) ) {
                if ( is_callable( $this->before ) ) {
                    call_user_func( $this->before );
                } elseif ( is_string( $this->before ) ) {
                    echo $this->before; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                }
            }

            $this->render_input( $current_value );

            if ( ! empty( $this->description ) ) {
                ?>
                <p id="<?php echo esc_attr( $this->get_id_attribute() . '-description' ); ?>" class="description">
                    <?php echo wp_kses_data( $this->description ); ?>
                </p>
                <?php
            }

            if ( ! empty( $this->after ) ) {
                if ( is_callable( $this->after ) ) {
                    call_user_func( $this->after );
                } elseif ( is_string( $this->after ) ) {
                    echo $this->after; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                }
            }

            echo '</div>';
        }

        /**
         * Renders the field's input.
         *
         * @since 1.0.0
         *
         * @param mixed $current_value Current value of the field.
         */
        public function render_input( $current_value ) {
            $this->maybe_resolve_dependencies();

            if ( $this->is_repeatable() ) {
                $current_value = (array) $current_value;

                $limit       = $this->get_repeatable_limit();
                $hide_button = $limit && count( $current_value ) === $limit;

                $this->open_repeatable_wrap();

                $srt_added = false;
                if ( ! in_array( 'screen-reader-text', $this->label_classes, true ) ) {
                    $this->label_classes[] = 'screen-reader-text';
                    $srt_added             = true;
                }

                $this->label_mode = 'explicit';

                $this->index = 0;
                foreach ( $current_value as $single_value ) {
                    $this->render_repeatable_item( $single_value );

                    $this->index++;
                }
                $this->index = null;

                $this->label_mode = 'no_assoc';

                if ( $srt_added ) {
                    $key = array_search( 'screen-reader-text', $this->label_classes, true );
                    unset( $this->label_classes[ $key ] );
                }

                $this->close_repeatable_wrap();

                $this->render_repeatable_add_button( $hide_button );
            } else {
                $this->render_single_input( $current_value );
            }
        }

        /**
         * Renders an item of the field if it is repeatable.
         *
         * @since 1.0.0
         *
         * @param mixed $current_value Current value of the item.
         */
        public function render_repeatable_item( $current_value ) {
            $this->maybe_resolve_dependencies();

            $this->open_repeatable_item_wrap();

            $this->render_label();
            $this->render_single_input( $current_value );

            $this->close_repeatable_item_wrap();
        }

        /**
         * Prints a label template.
         *
         * @since 1.0.0
         */
        public function print_label_template() {
            ?>
            <div id="{{ data.id }}-label-wrap" class="label-wrap">
                <# if ( ! _.isEmpty( data.label ) && 'skip' != data.labelMode ) { #>
                    <# if ( _.contains( [ 'no_assoc', 'aria_hidden' ], data.labelMode ) ) { #>
                        <span{{{ _.attrs( data.labelAttrs ) }}}>
                            {{{ data.label }}}
                            <# if ( ! data.repeatable && data.inputAttrs.required ) { #>
                                <?php echo $this->manager->get_field_required_markup(); /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>
                            <# } #>
                        </span>
                    <# } else { #>
                        <label{{{ _.attrs( data.labelAttrs ) }}}>
                            {{{ data.label }}}
                            <# if ( ! data.repeatable && data.inputAttrs.required ) { #>
                                <?php echo $this->manager->get_field_required_markup(); /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>
                            <# } #>
                        </label>
                    <# } #>
                <# } #>
            </div>
            <?php
        }

        /**
         * Prints a content template including the input.
         *
         * @since 1.0.0
         */
        public function print_content_template() {
            ?>
            <div id="{{ data.id }}-content-wrap" class="content-wrap">
                <# if ( ! _.isEmpty( data.before ) ) { #>
                    {{{ data.before }}}
                <# } #>

                <?php $this->print_input_template(); ?>

                <# if ( ! _.isEmpty( data.description ) ) { #>
                    <p id="{{ data.id }}-description" class="description">{{{ data.description }}}</p>
                <# } #>

                <# if ( ! _.isEmpty( data.after ) ) { #>
                    {{{ data.after }}}
                <# } #>
            </div>
            <?php
        }

        /**
         * Prints an input template.
         *
         * @since 1.0.0
         */
        public function print_input_template() {
            ?>
            <# if ( data.repeatable ) { #>
                <?php $this->print_open_repeatable_wrap_template(); ?>

                <# _.each( data.items, function( data ) { #>
                    <?php $this->print_repeatable_item_template(); ?>
                <# } ) #>

                <?php $this->print_close_repeatable_wrap_template(); ?>

                <?php $this->print_repeatable_add_button_template(); ?>
            <# } else { #>
                <?php $this->print_single_input_template(); ?>
            <# } #>
            <?php
        }

        /**
         * Prints a template for one item of a repeatable.
         *
         * @since 1.0.0
         */
        public function print_repeatable_item_template() {
            $this->print_open_repeatable_item_wrap_template();

            $this->print_label_template();
            $this->print_single_input_template();

            $this->print_close_repeatable_item_wrap_template();
        }

        /**
         * Transforms all field data into an array to be passed to JavaScript applications.
         *
         * @since 1.0.0
         *
         * @param mixed $current_value Current value of the field.
         * @return array Field data to be JSON-encoded.
         */
        public function to_json( $current_value ) {
            $this->maybe_resolve_dependencies();

            if ( $this->is_repeatable() ) {
                $current_value = (array) $current_value;

                $data = array(
                    'slug'            => $this->slug,
                    'id'              => $this->get_id_attribute(),
                    'label'           => $this->label,
                    'labelMode'       => $this->label_mode,
                    'labelAttrs'      => $this->get_label_attrs( array(), false ),
                    'items'           => array(),
                    'itemInitial'     => array(),
                    'repeatable'      => true,
                    'repeatableLimit' => $this->get_repeatable_limit(),
                );

                $srt_added = false;
                if ( ! in_array( 'screen-reader-text', $this->label_classes, true ) ) {
                    $this->label_classes[] = 'screen-reader-text';
                    $srt_added             = true;
                }

                $this->label_mode = 'explicit';

                $this->index         = '%index%';
                $data['itemInitial'] = $this->single_to_json( $this->default );

                $this->index = 0;
                foreach ( $current_value as $single_value ) {
                    $data['items'][] = $this->single_to_json( $single_value );

                    $this->index++;
                }
                $this->index = null;

                $this->label_mode = 'no_assoc';

                if ( $srt_added ) {
                    $key = array_search( 'screen-reader-text', $this->label_classes, true );
                    unset( $this->label_classes[ $key ] );
                }
            } else {
                $data = $this->single_to_json( $current_value );
            }

            $data['description'] = $this->description;
            $data['display']     = $this->display;

            if ( null !== $this->dependency_resolver ) {
                $data['dependencies'] = $this->dependency_resolver->get_dependencies();
            }

            if ( ! empty( $this->backbone_view ) ) {
                $data['backboneView'] = $this->backbone_view;
            }

            if ( ! empty( $this->before ) ) {
                if ( is_callable( $this->before ) ) {
                    ob_start();
                    call_user_func( $this->before );
                    $data['before'] = ob_get_clean();
                } elseif ( is_string( $this->before ) ) {
                    $data['before'] = $this->before;
                }
            }

            if ( ! empty( $this->after ) ) {
                if ( is_callable( $this->after ) ) {
                    ob_start();
                    call_user_func( $this->after );
                    $data['after'] = ob_get_clean();
                } elseif ( is_string( $this->after ) ) {
                    $data['after'] = $this->after;
                }
            }

            return $data;
        }

        /**
         * Validates a value for the field.
         *
         * @since 1.0.0
         *
         * @param mixed $value Value to validate. When null is passed, the method
         *                     assumes no value was sent.
         * @return mixed|WP_Error The validated value on success, or an error
         *                        object on failure.
         */
        public function validate( $value = null ) {
            $this->maybe_resolve_dependencies();

            if ( false === $this->display ) {
                return $value;
            }

            if ( $this->is_repeatable() ) {
                if ( empty( $value ) ) {
                    return array();
                }

                if ( ! is_array( $value ) ) {
                    return new WP_Error( 'field_repeatable_not_array', sprintf( $this->manager->get_message( 'field_repeatable_not_array' ), $this->label ) );
                }

                $validated = array();
                $errors    = new WP_Error();
                foreach ( $value as $single_value ) {
                    $original = $single_value;

                    $single_value = $this->pre_validate_single( $single_value );
                    if ( is_wp_error( $single_value ) ) {
                        $error_data = $single_value->get_error_data();
                        $errors->add( $single_value->get_error_code(), $single_value->get_error_message(), $error_data );
                        if ( isset( $error_data['validated'] ) ) {
                            $validated[] = $error_data['validated'];
                        }
                        continue;
                    }

                    $single_value = $this->validate_single( $single_value );
                    if ( is_wp_error( $single_value ) ) {
                        $error_data = $single_value->get_error_data();
                        $errors->add( $single_value->get_error_code(), $single_value->get_error_message(), $error_data );
                        if ( isset( $error_data['validated'] ) ) {
                            $validated[] = $error_data['validated'];
                        }
                        continue;
                    }

                    $single_value = $this->post_validate_single( $single_value, $original );
                    if ( is_wp_error( $single_value ) ) {
                        $error_data = $single_value->get_error_data();
                        $errors->add( $single_value->get_error_code(), $single_value->get_error_message(), $error_data );
                        if ( isset( $error_data['validated'] ) ) {
                            $validated[] = $error_data['validated'];
                        }
                        continue;
                    }

                    $validated[] = $single_value;
                }

                if ( ! empty( $errors->errors ) ) {
                    $error_data = array( 'errors' => $errors->errors );
                    if ( ! empty( $validated ) ) {
                        $error_data['validated'] = $validated;
                    }

                    return new WP_Error( 'field_repeatable_has_errors', sprintf( $this->manager->get_message( 'field_repeatable_has_errors' ), $this->label ), $error_data );
                }

                return $validated;
            }

            $original = $value;

            $value = $this->pre_validate_single( $value );
            if ( is_wp_error( $value ) ) {
                return $value;
            }

            $value = $this->validate_single( $value );
            if ( is_wp_error( $value ) ) {
                return $value;
            }

            return $this->post_validate_single( $value, $original );
        }

        /**
         * Renders a single input for the field.
         *
         * @since 1.0.0
         *
         * @param mixed $current_value Current field value.
         */
        abstract protected function render_single_input( $current_value );

        /**
         * Prints a single input template.
         *
         * @since 1.0.0
         */
        abstract protected function print_single_input_template();

        /**
         * Transforms single field data into an array to be passed to JavaScript applications.
         *
         * @since 1.0.0
         *
         * @param mixed $current_value Current value of the field.
         * @return array Field data to be JSON-encoded.
         */
        protected function single_to_json( $current_value ) {
            $data = array(
                'slug'         => $this->slug,
                'id'           => $this->get_id_attribute(),
                'name'         => $this->get_name_attribute(),
                'section'      => $this->section,
                'label'        => $this->label,
                'labelMode'    => $this->label_mode,
                'default'      => $this->default,
                'fieldset'     => $this->fieldset,
                'inputAttrs'   => $this->get_input_attrs( array(), false ),
                'labelAttrs'   => $this->get_label_attrs( array(), false ),
                'wrapAttrs'    => $this->get_wrap_attrs( false ),
                'currentValue' => $current_value,
            );

            if ( $this->is_repeatable() ) {
                $data['repeatable']      = true;
                $data['repeatableLimit'] = $this->get_repeatable_limit();
            }

            return $data;
        }

        /**
         * Validates a single value for the field.
         *
         * @since 1.0.0
         *
         * @param mixed $value Value to validate. When null is passed, the method
         *                     assumes no value was sent.
         * @return mixed|WP_Error The validated value on success, or an error
         *                        object on failure.
         */
        abstract protected function validate_single( $value = null );

        /**
         * Handles pre-validation of a single value.
         *
         * This method returns an error if the value of a required field is empty.
         *
         * @since 1.0.0
         *
         * @param mixed $value Vaule to handle pre-validation for.
         * @return mixed|WP_Error The value on success, or an error object on failure.
         */
        protected function pre_validate_single( $value ) {
            if ( $this->display && isset( $this->input_attrs['required'] ) && $this->input_attrs['required'] && $this->is_value_empty( $value ) ) {
                return new WP_Error( 'field_empty_required', sprintf( $this->manager->get_message( 'field_empty_required' ), $this->label ) );
            }

            return $value;
        }

        /**
         * Handles post-validation of a value.
         *
         * This method checks whether a custom validation callback is set and executes it.
         *
         * @since 1.0.0
         *
         * @param mixed $value          Value to handle post-validation for.
         * @param mixed $original_value Optional. Original value before any validation occurred. Default null.
         * @return mixed|WP_Error The value on success, or an error object on failure.
         */
        protected function post_validate_single( $value, $original_value = null ) {
            if ( $this->validate && is_callable( $this->validate ) ) {
                $value = call_user_func( $this->validate, $value, $original_value );
            }

            if ( empty( $original_value ) && empty( $value ) && is_string( $original_value ) && ! is_numeric( $original_value ) ) {
                $value = $original_value;
            }

            return $value;
        }

        /**
         * Checks whether a value is considered empty.
         *
         * @since 1.0.0
         *
         * @param mixed $value Value to check whether its empty.
         * @return bool True if the value is considered empty, false otherwise.
         */
        protected function is_value_empty( $value ) {

            // 0 being entered into a field means that it is not empty.
            if ( is_numeric( $value ) ) {
                return false;
            }

            return empty( $value );
        }

        /**
         * Returns the `id` attribute for the field.
         *
         * @since 1.0.0
         *
         * @return string `id` attribute value.
         */
        protected function get_id_attribute() {
            return $this->manager->make_id( $this->id, $this->index );
        }

        /**
         * Returns the `name` attribute for the field.
         *
         * @since 1.0.0
         *
         * @return string `name` attribute value.
         */
        protected function get_name_attribute() {
            return $this->manager->make_name( $this->id, $this->index );
        }

        /**
         * Opens the wrap for a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function open_repeatable_wrap() {
            $id = $this->get_id_attribute();

            $wrap_attrs = array(
                'id'    => $id . '-repeatable-wrap',
                'class' => 'plugin-lib-repeatable-wrap plugin-lib-repeatable-' . $this->slug . '-wrap',
            );

            echo '<span' . $this->attrs( $wrap_attrs ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        }

        /**
         * Closes the wrap for a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function close_repeatable_wrap() {
            echo '</span>';
        }

        /**
         * Opens the wrap for a repeatable field list item.
         *
         * @since 1.0.0
         */
        protected function open_repeatable_item_wrap() {
            $id = $this->get_id_attribute();

            $wrap_attrs = array(
                'id'    => $id . '-repeatable-item',
                'class' => 'plugin-lib-repeatable-item',
            );

            echo '<span' . $this->attrs( $wrap_attrs ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        }

        /**
         * Closes the wrap for a repeatable field list item.
         *
         * @since 1.0.0
         */
        protected function close_repeatable_item_wrap() {
            echo '</span>';
        }

        /**
         * Renders a button to add a new item to a repeatable field list.
         *
         * @since 1.0.0
         *
         * @param bool $hide_button Optional. Whether to initially hide the 'Add' button.
         *                          Default false.
         */
        protected function render_repeatable_add_button( $hide_button = false ) {
            $this->render_repeatable_button( 'add', sprintf( $this->manager->get_message( 'field_repeatable_add_button' ), $this->label ), $hide_button );
        }

        /**
         * Renders a button to remove an existing item from a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function render_repeatable_remove_button() {
            $this->render_repeatable_button( 'remove', sprintf( $this->manager->get_message( 'field_repeatable_remove_button' ), $this->label ) );
        }

        /**
         * Renders an add or remove button for a repeatable field.
         *
         * @since 1.0.0
         *
         * @param string $mode        Either 'add' or 'remove'.
         * @param string $message     The message to display on the button.
         * @param bool   $hide_button Optional. Whether to initially hide the button.
         *                            Default false.
         */
        protected function render_repeatable_button( $mode, $message, $hide_button = false ) {
            if ( ! $this->is_repeatable() ) {
                return;
            }

            $id = $this->get_id_attribute();

            if ( 'remove' === $mode ) {
                $core_class  = 'button-link button-link-delete';
                $target_mode = 'item';
            } else {
                $mode        = 'add';
                $core_class  = 'button';
                $target_mode = 'wrap';
            }

            $button_attrs = array(
                'type'        => 'button',
                'id'          => $id . '-repeatable-' . $mode . '-button',
                'class'       => 'plugin-lib-repeatable-' . $mode . '-button ' . $core_class,
                'data-target' => '#' . $id . '-repeatable-' . $target_mode,
            );
            if ( $hide_button ) {
                $button_attrs['style'] = 'display:none;';
            }

            echo '<button' . $this->attrs( $button_attrs ) . '>' . $message . '</button>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        }

        /**
         * Prints an open wrap template for a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function print_open_repeatable_wrap_template() {
            ?>
            <span id="{{ data.id }}-repeatable-wrap" class="plugin-lib-repeatable-wrap plugin-lib-repeatable-{{ data.slug }}-wrap">
            <?php
        }

        /**
         * Prints an close wrap template for a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function print_close_repeatable_wrap_template() {
            ?>
            </span>
            <?php
        }

        /**
         * Prints an open wrap template for a repeatable field list item.
         *
         * @since 1.0.0
         */
        protected function print_open_repeatable_item_wrap_template() {
            ?>
            <span id="{{ data.id }}-repeatable-item" class="plugin-lib-repeatable-item">
            <?php
        }

        /**
         * Prints an close wrap template for a repeatable field list item.
         *
         * @since 1.0.0
         */
        protected function print_close_repeatable_item_wrap_template() {
            ?>
            </span>
            <?php
        }

        /**
         * Prints a button template to add a new item to a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function print_repeatable_add_button_template() {
            $this->print_repeatable_button_template( 'add', sprintf( $this->manager->get_message( 'field_repeatable_add_button' ), '{{ data.label }}' ) );
        }

        /**
         * Prints a button template to remove an existing item from a repeatable field list.
         *
         * @since 1.0.0
         */
        protected function print_repeatable_remove_button_template() {
            $this->print_repeatable_button_template( 'remove', sprintf( $this->manager->get_message( 'field_repeatable_remove_button' ), '{{ data.label }}' ) );
        }

        /**
         * Prints an add or remove button template for a repeatable field.
         *
         * @since 1.0.0
         *
         * @param string $mode    Button mode. Either 'add' or 'remove'.
         * @param string $message Button message.
         */
        protected function print_repeatable_button_template( $mode, $message ) {
            $style = '';

            if ( 'remove' === $mode ) {
                $core_class  = 'button-link button-link-delete';
                $target_mode = 'item';
            } else {
                $mode        = 'add';
                $core_class  = 'button';
                $target_mode = 'wrap';
                $style       = '<# if ( data.repeatableLimit && data.repeatableLimit <= data.items.length ) { #>display:none;<# } #>';
            }

            ?>
            <# if ( data.repeatable ) { #>
                <button type="button" id="<?php echo esc_attr( '{{ data.id }}-repeatable-' . $mode . '-button' ); ?>" class="<?php echo esc_attr( 'plugin-lib-repeatable-' . $mode . '-button ' . $core_class ); ?>" data-target="<?php echo esc_attr( '#{{ data.id }}-repeatable-' . $target_mode ); ?>" style="<?php echo esc_attr( $style ); ?>">
                    <?php echo $message; /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>
                </button>
            <# } #>
            <?php
        }

        /**
         * Returns whether this field is repeatable.
         *
         * @since 1.0.0
         *
         * @return bool True if the field is repeatable, false otherwise.
         */
        protected function is_repeatable() {
            return (bool) $this->repeatable;
        }

        /**
         * Returns the amount of times the field can be repeated.
         *
         * @since 1.0.0
         *
         * @return int Repeat limit. Equals 0 if the field is not repeatable or
         *             if there is no limit set.
         */
        protected function get_repeatable_limit() {
            if ( is_numeric( $this->repeatable ) ) {
                return absint( $this->repeatable );
            }

            return 0;
        }

        /**
         * Returns the attributes for the field's wrap element.
         *
         * The wrap element is controlled by the field manager, so this method needs to be public.
         *
         * @since 1.0.0
         *
         * @param bool $as_string Optional. Whether to return the attributes as an attribute string.
         *                        Default true.
         * @return array|string Either an array of `$key => $value` pairs, or an attribute string if
         *                      `$as_string` is true.
         */
        public function get_wrap_attrs( $as_string = true ) {
            $this->maybe_resolve_dependencies();

            $wrap_attrs = array(
                'id'    => $this->get_id_attribute() . '-wrap',
                'class' => 'plugin-lib-wrap',
            );

            if ( ! $this->display ) {
                $wrap_attrs['class']      .= ' plugin-lib-hidden';
                $wrap_attrs['aria-hidden'] = 'true';
            }

            if ( ! empty( $this->wrap_classes ) ) {
                $wrap_attrs['class'] .= ' ' . implode( ' ', $this->wrap_classes );
            }

            if ( $as_string ) {
                return $this->attrs( $wrap_attrs );
            }

            return $wrap_attrs;
        }

        /**
         * Returns the attributes for the field's label.
         *
         * @since 1.0.0
         *
         * @param array $label_attrs Array of custom label attributes.
         * @param bool  $as_string   Optional. Whether to return them as an attribute
         *                           string. Default true.
         * @return array|string Either an array of `$key => $value` pairs, or an
         *                      attribute string if `$as_string` is true.
         */
        protected function get_label_attrs( $label_attrs = array(), $as_string = true ) {
            $base_label_attrs = array(
                'id' => $this->get_id_attribute() . '-label',
            );

            if ( 'explicit' === $this->label_mode ) {
                $base_label_attrs['for'] = $this->get_id_attribute();
            }

            if ( ! empty( $this->label_classes ) ) {
                $base_label_attrs['class'] = implode( ' ', $this->label_classes );
            }

            if ( 'aria_hidden' === $this->label_mode ) {
                $base_label_attrs['aria-hidden'] = 'true';
            }

            $all_label_attrs = array_merge( $base_label_attrs, $label_attrs );

            if ( $as_string ) {
                return $this->attrs( $all_label_attrs );
            }

            return $all_label_attrs;
        }

        /**
         * Returns the attributes for the field's input.
         *
         * @since 1.0.0
         *
         * @param array $input_attrs Array of custom input attributes.
         * @param bool  $as_string   Optional. Whether to return them as an attribute
         *                           string. Default true.
         * @return array|string Either an array of `$key => $value` pairs, or an
         *                      attribute string if `$as_string` is true.
         */
        protected function get_input_attrs( $input_attrs = array(), $as_string = true ) {
            $base_input_attrs = array(
                'id'   => $this->get_id_attribute(),
                'name' => $this->get_name_attribute(),
            );

            if ( ! empty( $this->input_classes ) ) {
                $base_input_attrs['class'] = implode( ' ', $this->input_classes );
            }

            $all_input_attrs = array_merge( $base_input_attrs, $input_attrs, $this->input_attrs );

            if ( isset( $all_input_attrs['required'] ) && $all_input_attrs['required'] ) {
                $all_input_attrs['data-required'] = 'true';
            }

            if ( ! $this->display ) {
                $all_input_attrs['tabindex'] = '-1';

                if ( isset( $all_input_attrs['required'] ) && $all_input_attrs['required'] ) {
                    $all_input_attrs['required'] = false;
                }
            }

            if ( $as_string ) {
                return $this->attrs( $all_input_attrs );
            }

            return $all_input_attrs;
        }

        /**
         * Transforms an array of attributes into an attribute string.
         *
         * @since 1.0.0
         *
         * @param array $attrs Array of `$key => $value` pairs.
         * @return string Attribute string.
         */
        protected function attrs( $attrs ) {
            $output = '';

            foreach ( $attrs as $attr => $value ) {
                if ( is_bool( $value ) ) {
                    if ( $value ) {
                        $output .= ' ' . $attr;
                    }
                } else {
                    if ( is_array( $value ) || is_object( $value ) ) {
                        $value = wp_json_encode( $value );
                    }

                    if ( is_string( $value ) && false !== strpos( $value, '"' ) ) {
                        $output .= ' ' . $attr . "='" . esc_attr( $value ) . "'";
                    } else {
                        $output .= ' ' . $attr . '="' . esc_attr( $value ) . '"';
                    }
                }
            }

            return $output;
        }

        /**
         * Resolves all dependencies of this field, if applicable.
         *
         * @since 1.0.0
         *
         * @return bool True if dependencies were resolved, false if nothing changed.
         */
        protected function maybe_resolve_dependencies() {
            if ( ! $this->dependency_resolver ) {
                return false;
            }

            if ( $this->dependency_resolver->resolved() ) {
                return false;
            }

            $resolved_props = $this->dependency_resolver->resolve_dependencies();
            foreach ( $resolved_props as $prop => $value ) {
                if ( ! isset( $this->$prop ) ) {
                    continue;
                }

                $this->$prop = $value;
            }

            return true;
        }

        /**
         * Returns names of the properties that must not be set through constructor arguments.
         *
         * @since 1.0.0
         *
         * @return array Array of forbidden properties.
         */
        protected function get_forbidden_keys() {
            return array( 'manager', 'dependency_resolver', 'id', 'slug', 'label_mode', 'input_attrs', 'backbone_view', 'index' );
        }
    }

endif;