Subversion Repositories web.active

Rev

Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire InputfieldWrapper
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 *
 * About InputfieldWrapper
 * =======================
 * A type of Inputfield that is designed specifically to wrap other Inputfields.
 * The most common example of an InputfieldWrapper is a <form>.
 * 
 * #pw-summary A type of Inputfield that contains other Inputfield objects as children. Commonly a form or a fieldset.  
 *
 * InputfieldWrapper is not designed to render an Inputfield specifically, but you can set a value attribute
 * containing content that will be rendered before the wrapper.
 *
 * @property bool $renderValueMode True when only rendering values, i.e. no inputs (default=false). #pw-internal
 * @property bool $quietMode True to suppress label, description and notes, often combined with renderValueMode (default=false). #pw-internal
 * @property int $columnWidthSpacing Percentage spacing between columns or 0 for none. Default pulled from `$config->inputfieldColumnWidthSpacing`. #pw-internal
 * @property bool $useDependencies Whether or not to consider `showIf` and `requiredIf` dependencies during processing (default=true). #pw-internal
 * @property bool|null $InputfieldWrapper_isPreRendered Whether or not children have been pre-rendered (internal use only) #pw-internal
 * @property InputfieldsArray|null $children Inputfield instances that are direct children of this InputfieldWrapper.  #pw-group-properties
 * 
 * @method string renderInputfield(Inputfield $inputfield, $renderValueMode = false) #pw-group-output
 * @method Inputfield new($typeName, $name = '', $label = '', array $settings = []) #pw-group-manipulation
 * 
 * @property InputfieldAsmSelect $InputfieldAsmSelect
 * @property InputfieldButton $InputfieldButton
 * @property InputfieldCheckbox $InputfieldCheckbox
 * @property InputfieldCheckboxes $InputfieldCheckboxes
 * @property InputfieldCKEditor $InputfieldCkeditor
 * @property InputfieldCommentsAdmin $InputfieldCommentsAdmin
 * @property InputfieldDatetime $InputfieldDatetime
 * @property InputfieldEmail $InputfieldEmail
 * @property InputfieldFieldset $InputfieldFieldset
 * @property InputfieldFieldsetClose $InputfieldlFieldsetClose
 * @property InputfieldFieldsetOpen $InputfieldFieldsetOpen
 * @property InputfieldFieldsetTabOpen $InputfieldFieldsetTabOpen
 * @property InputfieldFile $InputfieldFile
 * @property InputfieldFloat $InputfieldFloat
 * @property InputfieldForm $InputfieldForm
 * @property InputfieldHidden $InputfieldHidden
 * @property InputfieldIcon $InputfieldIcon
 * @property InputfieldImage $InputfieldImage
 * @property InputfieldInteger $InputfieldInteger
 * @property InputfieldMarkup $InputfieldMarkup
 * @property InputfieldName $InputfieldName
 * @property InputfieldPage $InputfieldPage
 * @property InputfieldPageAutocomplete $InputfieldPageAutocomplete
 * @property InputfieldPageListSelect $InputfieldPageListSelect
 * @property InputfieldPageListSelectMultiple $InputfieldPageListSelectMultiple
 * @property InputfieldPageName $InputfieldPageName
 * @property InputfieldPageTable $InputfieldPageTable
 * @property InputfieldPageTitle $InputfieldPageTitle
 * @property InputfieldPassword $InputfieldPassword
 * @property InputfieldRadios $InputfieldRadios
 * @property InputfieldRepeater $InputfieldRepeater
 * @property InputfieldSelect $InputfieldSelect
 * @property InputfieldSelectMultiple $InputfieldSelectMultiple
 * @property InputfieldSelector $InputfieldSelector
 * @property InputfieldSubmit $InputfieldSubmit
 * @property InputfieldText $InputfieldText
 * @property InputfieldTextarea $InputfieldTextarea
 * @property InputfieldTextTags $InputfieldTextTags
 * @property InputfieldToggle $InputfieldToggle
 * @property InputfieldURL $InputfieldURL
 * @property InputfieldWrapper $InputfieldWrapper
 *
 */

class InputfieldWrapper extends Inputfield implements \Countable, \IteratorAggregate {

    /**
     * Set to true for debugging optimization of property accesses
     * 
     * #pw-internal
     * 
     */
    const debugPropertyAccess = false;
    
    /**
     * Markup used during the render() method - customize with InputfieldWrapper::setMarkup($array)
     *
     */
    static protected $defaultMarkup = array(
        'list' => "<ul {attrs}>{out}</ul>",
        'item' => "<li {attrs}>{out}</li>", 
        'item_label' => "<label class='InputfieldHeader ui-widget-header{class}' for='{for}'>{out}</label>",
        'item_label_hidden' => "<label class='InputfieldHeader InputfieldHeaderHidden ui-widget-header{class}'><span>{out}</span></label>",
        'item_content' => "<div class='InputfieldContent ui-widget-content{class}'>{out}</div>", 
        'item_error' => "<p class='InputfieldError ui-state-error'><i class='fa fa-fw fa-flash'></i><span>{out}</span></p>",
        'item_description' => "<p class='description'>{out}</p>", 
        'item_head' => "<h2>{out}</h2>", 
        'item_notes' => "<p class='notes'>{out}</p>",
        'item_detail' => "<p class='detail'>{out}</p>", 
        'item_icon' => "<i class='fa fa-fw fa-{name}'></i> ",
        'item_toggle' => "<i class='toggle-icon fa fa-fw fa-angle-down' data-to='fa-angle-down fa-angle-right'></i>", 
        // ALSO: 
        // InputfieldAnything => array( any of the properties above to override on a per-Inputifeld basis)
    );

    static protected $markup = array();

    /**
     * Classes used during the render() method - customize with InputfieldWrapper::setClasses($array)
     *
     */
    static protected $defaultClasses = array(
        'form' => '', // additional clases for InputfieldForm (optional)
        'list' => 'Inputfields',
        'list_clearfix' => 'ui-helper-clearfix', 
        'item' => 'Inputfield {class} Inputfield_{name} ui-widget',
        'item_label' => '', // additional classes for InputfieldHeader (optional)
        'item_content' => '',  // additional classes for InputfieldContent (optional)
        'item_required' => 'InputfieldStateRequired', // class is for Inputfield
        'item_error' => 'ui-state-error InputfieldStateError', // note: not the same as markup[item_error], class is for Inputfield
        'item_collapsed' => 'InputfieldStateCollapsed',
        'item_column_width' => 'InputfieldColumnWidth',
        'item_column_width_first' => 'InputfieldColumnWidthFirst',
        'item_show_if' => 'InputfieldStateShowIf',
        'item_required_if' => 'InputfieldStateRequiredIf'
        // ALSO: 
        // InputfieldAnything => array( any of the properties above to override on a per-Inputifeld basis)
    );

    static protected $classes = array();

    /**
     * Instance of InputfieldsArray, if this Inputfield contains child Inputfields
     *
     * @var InputfieldsArray
     * 
     */
    protected $children;

    /**
     * Array of Inputfields that had their processing delayed by dependencies. 
     *
     */
    protected $delayedChildren = array();

    /**
     * Label displayed when a value is required but missing
     *
     */
    protected $requiredLabel = 'Missing required value';

    /**
     * Whether or not column width is handled internally
     * 
     * @var bool
     * 
     */
    protected $useColumnWidth = true;

    /**
     * Construct the Inputfield, setting defaults for all properties
     *
     */
    public function __construct() {
        parent::__construct();
        $this->children = new InputfieldsArray();
        $this->set('skipLabel', Inputfield::skipLabelFor); 
        $this->set('useDependencies', true); // whether or not to use consider field dependencies during processing
        $this->set('renderValueMode', false); 
        $this->set('quietMode', false); // suppress label, description and notes
        $this->set('columnWidthSpacing', 0);
    }

    /**
     * Wired to API
     * 
     */
    public function wired() {
        
        $config = $this->wire()->config;
        
        $this->wire($this->children);
        $this->requiredLabel = $this->_('Missing required value');
        
        $columnWidthSpacing = $config->inputfieldColumnWidthSpacing;
        $columnWidthSpacing = is_null($columnWidthSpacing) ? 1 : (int) $columnWidthSpacing;
        if($columnWidthSpacing > 0) $this->set('columnWidthSpacing', $columnWidthSpacing);
    
        $columnWidthSpacing = null;
        $settings = $config->InputfieldWrapper;
        
        if(is_array($settings)) {
            foreach($settings as $key => $value) {
                if($key == 'requiredLabel') {
                    $this->requiredLabel = $value;
                } else if($key == 'useColumnWidth') {
                    $this->useColumnWidth = $value;
                } else {
                    $this->set($key, $value);
                }
            }
        }
        
        parent::wired();
    }

    /**
     * Get a child Inputfield having a name attribute matching the given $key.
     * 
     * This method can also get settings, attributes or API variables, so long as they don't
     * collide with an Inputfield name. For that reason, you may prefer to use the `Inputfield::getSetting()`,
     * `Inputfield::attr()` or `Wire::wire()` methods for those other purposes. 
     * 
     * If you want a method that can only return a matching Inputfield object, use the 
     * `InputfieldWrapper::getChildByName()` method .
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @param string $key Name of Inputfield or setting/property to retrieve. 
     * @return Inputfield|mixed 
     * @see InputfieldWrapper::getChildByName()
     * @throws WireException Only in core development/debugging, otherwise does not throw exceptions.
     *
     */
    public function get($key) {
        $inputfield = $this->getChildByName($key);
        if($inputfield) return $inputfield;
        if(self::debugPropertyAccess) throw new WireException("Access of attribute or setting: $key");
        $value = $this->wire($key);
        if($value) return $value; 
        if($key === 'children') return $this->children(); 
        if(($value = parent::get($key)) !== null) return $value; 
        return null;
    }

    /**
     * Provides direct reference to attributes and settings, and falls back to Inputfield children
     * 
     * This is different behavior from the get() method. 
     *
     * @param string $key
     * @return mixed|null
     *
     */
    public function __get($key) {
        if($key === 'children') return $this->children();
        if(strpos($key, 'Inputfield') === 0 && strlen($key) > 10) {
            if($key === 'InputfieldWrapper') return $this->wire(new InputfieldWrapper()); 
            $value = $this->wire()->modules->get($key);
            if($value && $value instanceof Inputfield) return $value;
            if(wireClassExists($key)) return $this->wire(new $key()); 
            $value = null;
        }
        $value = parent::get($key); 
        if(is_null($value)) $value = $this->getChildByName($key);
        return $value; 
    }

    /**
     * Add an Inputfield item as a child (also accepts array definition)
     * 
     * Since 3.0.110: If given a string value, it is assumed to be an Inputfield type that you 
     * want to add. In that case, it will create the Inputfield and return it instead of $this. 
     * 
     * #pw-group-manipulation
     *
     * @param Inputfield|array|string $item
     * @return Inputfield|InputfieldWrapper|$this
     * @see InputfieldWrapper::import()
     *
     */
    public function add($item) {
        if(is_string($item)) {
            return $this->___new($item);
        } else if(is_array($item)) {
            $this->importArray($item); 
        } else {
            $this->children()->add($item); 
            $item->setParent($this); 
        }
        return $this; 
    }

    /**
     * Create a new Inputfield, add it to this InputfieldWrapper, and return the new Inputfield
     * 
     * - Only the $typeName argument is required. 
     * - You may optionally substitute the $settings argument for the $name or $label arguments.
     * - You may optionally substitute Inputfield “description” property for $settings argument.
     * 
     * #pw-group-manipulation
     * 
     * @param string $typeName Inputfield type, i.e. “InputfieldCheckbox” or just “checkbox” for short. 
     * @param string|array $name Name of input (or substitute $settings here). 
     * @param string|array $label Label for input (or substitute $settings here).
     * @param array|string $settings Settings to add to Inputfield (optional). Or if string, assumed to be “description”.
     * @return Inputfield|InputfieldSelect|InputfieldWrapper An Inputfield instance ready to populate with additional properties/attributes.
     * @throws WireException If you request an unknown Inputfield type
     * @since 3.0.110
     * 
     */
    public function ___new($typeName, $name = '', $label = '', $settings = array()) {
        
        if(is_array($name)) {
            $settings = $name;
            $name = '';
        } else if(is_array($label)) {
            $settings = $label;
            $label = '';
        } 
        
        if(strpos($typeName, 'Inputfield') !== 0) {
            $typeName = "Inputfield" . ucfirst($typeName);
        }
    
        /** @var Inputfield|InputfieldSelect|InputfieldWrapper $inputfield */
        $inputfield = $this->wire('modules')->getModule($typeName);
        
        if(!$inputfield && wireClassExists($typeName)) {
            $inputfield = $this->wire(new $typeName());
        }
        
        if(!$inputfield || !$inputfield instanceof Inputfield) {
            throw new WireException("Unknown Inputfield type: $typeName");
        }
        
        if(strlen($name)) $inputfield->attr('name', $name);
        if(strlen($label)) $inputfield->label = $label;
    
        if(is_array($settings)) {
            foreach($settings as $key => $value) {
                $inputfield->set($key, $value);
            }
        } else if(is_string($settings)) {
            $inputfield->description = $settings;
        }
        
        $this->add($inputfield);
        
        return $inputfield;
    }

    /**
     * Import the given Inputfield items as children
     * 
     * If given an `InputfieldWrapper`, it will import the children of it and
     * exclude the wrapper itself. This is different from `InputfieldWrapper::add()` 
     * in that add() would add the wrapper, not just the children. See also 
     * the `InputfieldWrapper::importArray()` method. 
     * 
     * #pw-group-manipulation
     * 
     * @param InputfieldWrapper|array|InputfieldsArray $items Wrapper containing items to add
     * @return $this
     * @throws WireException
     * @see InputfieldWrapper::add(), InputfieldWrapper::importArray()
     * 
     */
    public function import($items) {
        if($items instanceof InputfieldWrapper || $items instanceof InputfieldsArray) {
            foreach($items as $item) {
                $this->add($item);
            }
        } else if(is_array($items)) {
            $this->importArray($items);
        } else if($items instanceof Inputfield) {
            $this->add($items);
        } else {
            throw new WireException("InputfieldWrapper::import() requires InputfieldWrapper, InputfieldsArray, array, or Inputfield");
        }
        return $this;
    }

    /**
     * Prepend an Inputfield to this instance’s children.
     * 
     * #pw-group-manipulation
     * 
     * @param Inputfield $item Item to prepend
     * @return $this
     *
     */
    public function prepend(Inputfield $item) {
        $item->setParent($this); 
        $this->children()->prepend($item); 
        return $this; 
    }

    /**
     * Append an Inputfield to this instance’s children.
     * 
     * #pw-group-manipulation
     * 
     * @param Inputfield $item Item to append
     * @return $this
     *
     */
    public function append(Inputfield $item) {
        $item->setParent($this); 
        $this->children()->append($item); 
        return $this; 
    }

    /**
     * Insert new or existing Inputfield before or after another
     * 
     * @param Inputfield|array|string $item New or existing item Inputfield, name, or new item array to insert.
     * @param Inputfield|string $existingItem Existing item or item name you want to insert before.
     * @param bool $before True to insert before, false to insert after (default=false).
     * @return $this
     * @throws WireException
     * @since 3.0.196
     * 
     */
    public function insert($item, $existingItem, $before = false) {
        
        $children = $this->children();
        
        if($existingItem instanceof Inputfield) {
            // ok
        } else if(is_string($existingItem)) {
            $name = $existingItem;
            $existingItem = $this->getByName($name);
            if(!$existingItem) throw new WireException("Cannot find Inputfield[name=$name] to insert");
        } else {
            throw new WireException('Invalid value for $existingItem argument'); 
        }
        
        if(is_array($item)) {
            // new item definition
            if(isset($item['name'])) {
                // first check if there's an existing item with this name
                $f = $this->getByName($item['name']); 
                if($f) return $this->insert($f, $existingItem, $before);
            }
            $n = $children->count();
            $this->add($item);
            if($children->count() > $n) {
                // new item was added
                $item = $children->last();
                $children->remove($item);
            } else {
                throw new WireException('Unable to insert new item: ' . print_r($item, true));
            }
        } else if(!$item instanceof Inputfield) {
            // get item to insert by name
            $name = (string) $item;
            $item = $this->getByName($name);
            if(!$item) {
                // if named item isn't found, create one 
                $item = $this->___new('text', $name, $name); 
            }
        }
        
        if($children->has($existingItem)) {
            // existing item is a direct child of this InputfieldWrapper
            $item->setParent($this);
            $method = $before ? 'insertBefore' : 'insertAfter';
            $children->$method($item, $existingItem);
        } else {
            // find existing item recursively
            $f = $this->getByName($existingItem->attr('name')); 
            if($f && $f->parent) {
                // existing item was found
                $existingItem = $f;
                $existingItem->parent->insert($item, $existingItem, $before);
            } else {
                // existing item not found, add it as direct child
                $this->add($existingItem);
                $existingItem->parent->insert($item, $existingItem, $before);
            }
        }
        
        return $this; 
    }

    /**
     * Insert one Inputfield before one that’s already there.
     * 
     * Note: string or array values for either argument require 3.0.196+. 
     * 
     * ~~~~~
     * // example 1: Get existing Inputfields and insert first_name before last_name
     * $firstName = $form->getByName('first_name');
     * $lastName = $form->getByName('last_name'); 
     * $form->insertBefore($firstName, $lastName); 
     * 
     * // example 2: Same as above but use Inputfield names (3.0.196+)
     * $form->insertBefore('first_name', 'last_name'); 
     * 
     * // example 3: Create new Inputfield and insert before last_name (3.0.196+)
     * $form->insertBefore([ 'type' => 'text', 'name' => 'first_name' ], 'last_name'); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     * 
     * @param Inputfield|array|string $item Item to insert 
     * @param Inputfield|string $existingItem Existing item you want to insert before.
     * @return $this
     *
     */
    public function insertBefore($item, $existingItem) {
        return $this->insert($item, $existingItem, true);
    }

    /**
     * Insert one Inputfield after one that’s already there.
     * 
     * Note: string or array values for either argument require 3.0.196+.
     * 
     * ~~~~~
     * // example 1: Get existing Inputfields, insert last_name after first_name
     * $lastName = $form->getByName('last_name');
     * $firstName = $form->getByName('first_name');
     * $form->insertAfter($lastName, $firstName);
     *
     * // example 2: Same as above but use Inputfield names (3.0.196+)
     * $form->insertBefore('last_name', 'first_name');
     *
     * // example 3: Create new Inputfield and insert after first_name (3.0.196+)
     * $form->insertAfter([ 'type' => 'text', 'name' => 'last_name' ], 'first_name');
     * ~~~~~
     * 
     * #pw-group-manipulation
     * 
     * @param Inputfield|array|string $item Item to insert
     * @param Inputfield|string $existingItem Existing item you want to insert after.
     * @return $this
     *
     */
    public function insertAfter($item, $existingItem) {
        return $this->insert($item, $existingItem, false);
    }

    /**
     * Remove an Inputfield from this instance’s children.
     * 
     * #pw-group-manipulation
     * 
     * @param Inputfield|string $item Inputfield object or name
     * @return $this
     *
     */
    public function remove($item) {
        if(!$item) return $this;
        if(!$item instanceof Inputfield) {
            if(!is_string($item)) return $this;
            $item = $this->getChildByName($item);   
            if(!$item) return $this;
        }
        if($this->children()->has($item)) {
            $this->children()->remove($item);
        } if($this->getChildByName($item->attr('name')) && $item->parent) {
            $item->parent->remove($item);
        }
        return $this; 
    }

    /**
     * Prepare children for rendering by creating any fieldset groups
     * 
     */
    protected function preRenderChildren() {

        if($this->getSetting('InputfieldWrapper_isPreRendered')) return $this->children(); 

        $children = $this->wire(new InputfieldWrapper()); 
        $wrappers = array($children);

        foreach($this->children() as $inputfield) {

            $wrapper = end($wrappers); 

            if($inputfield instanceof InputfieldFieldsetClose) {
                if(count($wrappers) > 1) array_pop($wrappers); 
                continue; 

            } else if($inputfield instanceof InputfieldFieldsetOpen) {
                $inputfield->set('InputfieldWrapper_isPreRendered', true); 
                array_push($wrappers, $inputfield); 
            } 

            $inputfield->unsetParent();
            $wrapper->add($inputfield); 
        }

        return $children;
    }

    /**
     * Cached class parents indexed by Inputfield class name
     * 
     * @var array
     * 
     */
    static protected $classParents = array();

    /**
     * Get array of parent Inputfield classes for given Inputfield (excluding the base Inputfield class)
     * 
     * @param Inputfield|string $inputfield
     * @return array
     * 
     */
    protected function classParents($inputfield) {
        $p = &self::$classParents;
        $c = is_object($inputfield) ? $inputfield->className() : $inputfield;
        if(!isset($p[$c])) {
            $p[$c] = array();
            foreach(wireClassParents($inputfield) as $parentClass) {
                if(strpos($parentClass, 'Inputfield') !== 0 || $parentClass === 'Inputfield') break;
                $p[$c][] = $parentClass;
            }
        }
        return $p[$c];  
    }

    /**
     * Prepare Inputfield for attributes used during rendering
     * 
     * #pw-internal
     * 
     * @param Inputfield $inputfield
     * @param array $markup
     * @param array $classes
     * @since 3.0.144
     * 
     */
    private function attributeInputfield(Inputfield $inputfield, &$markup, &$classes) {
        
        $inputfieldClass = $inputfield->className();
        $markupTemplate = array('attr' => array(), 'wrapAttr' => array(), 'set' => array());
        $markupKeys = array($inputfieldClass, "name=$inputfield->name", "id=$inputfield->id");
        $classKeys = array('class', 'wrapClass', 'headerClass', 'contentClass');
        $addClasses = array();
        $attr = array();
        $wrapAttr = array();
        $sets = array();

        foreach($markupKeys as $key) {
            if(isset($markup[$key])) $markup = array_merge($markup, $markup[$key]);
            if(isset($classes[$key])) $classes = array_merge($classes, $classes[$key]);
        }
        
        foreach(array_merge($this->classParents($inputfield), $markupKeys) as $key) {
            if(!isset($markup[$key])) continue;
            $markupParent = array_merge($markupTemplate, $markup[$key]);
            foreach($classKeys as $classKey) {
                if(!empty($markupParent[$classKey])) {
                    $addClasses[$classKey] = $markupParent[$classKey];
                }
            }
            foreach($markupParent['attr'] as $k => $v) {
                $attr[$k] = $v;
            }
            foreach($markupParent['wrapAttr'] as $k => $v) {
                $wrapAttr[$k] = $v;
            }
            foreach($markupParent['set'] as $k => $v) {
                $sets[$k] = $v;
            }
        }

        foreach($attr as $attrName => $attrVal) {
            $inputfield->attr($attrName, $attrVal);
        }
        foreach($wrapAttr as $attrName => $attrVal) {
            $inputfield->wrapAttr($attrName, $attrVal);
        }
        foreach($addClasses as $classKey => $class) {
            $inputfield->addClass($class, $classKey);
        }
        foreach($sets as $setName => $setVal) {
            $inputfield->set($setName, $setVal); 
        }
    }

    /**
     * Render this Inputfield and the output of its children.
     * 
     * #pw-group-output
     *
     * @todo this method has become too long/complex, move to its own pluggable class and split it up a lot
     * @return string
     *
     */
    public function ___render() {
        
        $sanitizer = $this->wire()->sanitizer;
        $out = '';
        $children = $this->preRenderChildren();
        $columnWidthTotal = 0;
        $columnWidthSpacing = $this->getSetting('columnWidthSpacing');
        $quietMode = $this->getSetting('quietMode');
        $lastInputfield = null;
        $_markup = array_merge(self::$defaultMarkup, self::$markup);
        $_classes = array_merge(self::$defaultClasses, self::$classes);
        $markup = array();
        $classes = array();
        $useColumnWidth = $this->useColumnWidth;
        $renderAjaxInputfield = $this->wire()->config->ajax ? $this->wire()->input->get('renderInputfieldAjax') : null;
        $lockedStates = array(Inputfield::collapsedNoLocked, Inputfield::collapsedYesLocked, Inputfield::collapsedBlankLocked);
        
        if($useColumnWidth === true && isset($_classes['form']) && strpos($_classes['form'], 'InputfieldFormNoWidths') !== false) {
            $useColumnWidth = false;
        }
    
        // show description for tabs
        $description = $quietMode ? '' : $this->getSetting('description'); 
        if($description && wireClassExists("InputfieldFieldsetTabOpen") && $this instanceof InputfieldFieldsetTabOpen) {
            $out .= str_replace('{out}', nl2br($this->entityEncode($description, true)), $_markup['item_head']);
        }
        
        foreach($children as $inputfield) {
            
            if($renderAjaxInputfield && $inputfield->attr('id') !== $renderAjaxInputfield 
                && !$inputfield instanceof InputfieldWrapper) {
                
                $skip = true;
                foreach($inputfield->getParents() as $parent) {
                    /** @var InputfieldWrapper $parent */
                    if($parent->attr('id') === $renderAjaxInputfield) $skip = false;
                }
                if($skip && !empty($parents)) continue;
            }
            
            list($markup, $classes) = array($_markup, $_classes);
            $this->attributeInputfield($inputfield, $markup, $classes);
            
            $renderValueMode = $this->getSetting('renderValueMode'); 
            $collapsed = (int) $inputfield->getSetting('collapsed'); 
            $required = $inputfield->getSetting('required');
            $requiredIf = $required ? $inputfield->getSetting('requiredIf') : false;
            $showIf = $inputfield->getSetting('showIf'); 
            
            if($collapsed == Inputfield::collapsedHidden) continue; 
            if(in_array($collapsed, $lockedStates)) $renderValueMode = true;

            $ffOut = $this->renderInputfield($inputfield, $renderValueMode);
            if(!strlen($ffOut)) continue;
            $collapsed = (int) $inputfield->getSetting('collapsed');  // retrieve again after render
            $entityEncodeText = $inputfield->getSetting('entityEncodeText') === false ? false : true;
            
            $errorsOut = '';
            if(!$inputfield instanceof InputfieldWrapper) {
                $errors = $inputfield->getErrors(true);
                if(count($errors)) {
                    $collapsed = $renderValueMode ? Inputfield::collapsedNoLocked : Inputfield::collapsedNo;
                    $errorsOut = implode(', ', $errors);
                }
            } else $errors = array();
        
            foreach(array('error', 'description', 'head', 'notes', 'detail') as $property) {
                $text = $property == 'error' ? $errorsOut : $inputfield->getSetting($property); 
                if($property === 'detail' && !is_string($text)) continue; // may not be necessary
                if(!empty($text) && !$quietMode) {
                    if($entityEncodeText) {
                        $text = $inputfield->entityEncode($text, true);
                    }
                    if($inputfield->textFormat != Inputfield::textFormatMarkdown) {
                        $text = str_replace('{out}', nl2br($text), $markup["item_$property"]);
                    }
                } else {
                    $text = '';
                }
                $_property = '{' . $property . '}';
                if(strpos($markup['item_content'], $_property) !== false) {
                    $markup['item_content'] = str_replace($_property, $text, $markup['item_content']);
                } else if(strpos($markup['item_label'], $_property) !== false) {
                    $markup['item_label'] = str_replace($_property, $text, $markup['item_label']);
                } else if($text && ($property == 'notes' || $property == 'detail')) {
                    $ffOut .= $text;
                } else if($text) {
                    $ffOut = $text . $ffOut;
                }
            }
            
            if(!$quietMode) {
                $prependMarkup = $inputfield->getSetting('prependMarkup');
                if($prependMarkup) $ffOut = $prependMarkup . $ffOut;
                $appendMarkup = $inputfield->getSetting('appendMarkup');
                if($appendMarkup) $ffOut .= $appendMarkup;
            }
            
            // The inputfield classname is always used in its wrapping element
            $ffAttrs = array(
                'class' => str_replace(
                    array('{class}', '{name}'), 
                    array($inputfield->className(), $inputfield->attr('name')
                ), 
                $classes['item'])
            );
            if($inputfield instanceof InputfieldItemList) $ffAttrs['class'] .= " InputfieldItemList";
            if($collapsed) $ffAttrs['class'] .= " collapsed$collapsed";

            if(count($errors)) $ffAttrs['class'] .= ' ' . $classes['item_error'];
            if($required) $ffAttrs['class'] .= ' ' . $classes['item_required']; 
            if(strlen($showIf) && !$this->getSetting('renderValueMode')) { // note: $this->renderValueMode (rather than $renderValueMode) is intentional
                $ffAttrs['data-show-if'] = $showIf;
                $ffAttrs['class'] .= ' ' . $classes['item_show_if'];
            }
            if(strlen($requiredIf)) {
                $ffAttrs['data-required-if'] = $requiredIf; 
                $ffAttrs['class'] .= ' ' . $classes['item_required_if']; 
            }

            if($collapsed && $collapsed !== Inputfield::collapsedNever) {
                $isEmpty = $inputfield->isEmpty();
                if(($isEmpty && $inputfield instanceof InputfieldWrapper && $collapsed !== Inputfield::collapsedPopulated) || 
                    $collapsed === Inputfield::collapsedYes ||
                    $collapsed === Inputfield::collapsedYesLocked ||
                    $collapsed === true || 
                    $collapsed === Inputfield::collapsedYesAjax ||
                    ($isEmpty && $collapsed === Inputfield::collapsedBlank) ||
                    ($isEmpty && $collapsed === Inputfield::collapsedBlankAjax) ||
                    ($isEmpty && $collapsed === Inputfield::collapsedBlankLocked) ||
                    (!$isEmpty && $collapsed === Inputfield::collapsedPopulated)) {
                        $ffAttrs['class'] .= ' ' . $classes['item_collapsed'];
                    }
            }
            
            if($inputfield instanceof InputfieldWrapper) {
                // if the child is a wrapper, then id, title and class attributes become part of the LI wrapper
                foreach($inputfield->getAttributes() as $k => $v) {
                    if(in_array($k, array('id', 'title', 'class'))) {
                        $ffAttrs[$k] = isset($ffAttrs[$k]) ? $ffAttrs[$k] . " $v" : $v; 
                    }
                }
            } 
        
            // if inputfield produced no output, then move to next
            if(!strlen($ffOut)) continue;

            // wrap the inputfield output
            $attrs = '';
            $label = $inputfield->getSetting('label');
            $skipLabel = $inputfield->getSetting('skipLabel'); 
            $skipLabel = is_bool($skipLabel) || empty($skipLabel) ? (bool) $skipLabel : (int) $skipLabel; // force as bool or int
            if(!strlen($label) && $skipLabel !== Inputfield::skipLabelBlank && $inputfield->className() != 'InputfieldWrapper') {
                $label = $inputfield->attr('name');
            }
            if(($label || $quietMode) && $skipLabel !== Inputfield::skipLabelMarkup) {
                $for = $skipLabel || $quietMode ? '' : $inputfield->attr('id');
                // if $inputfield has a property of entityEncodeLabel with a value of boolean FALSE, we don't entity encode
                $entityEncodeLabel = $inputfield->getSetting('entityEncodeLabel');
                if(is_int($entityEncodeLabel) && $entityEncodeLabel >= Inputfield::textFormatBasic) {
                    // uses an Inputfield::textFormat constant
                    $label = $inputfield->entityEncode($label, $entityEncodeLabel);
                } else if($entityEncodeLabel !== false) {
                    $label = $inputfield->entityEncode($label);
                }
                $icon = $inputfield->getSetting('icon');
                $icon = $icon ? str_replace('{name}', $sanitizer->name(str_replace(array('icon-', 'fa-'), '', $icon)), $markup['item_icon']) : ''; 
                $toggle = $collapsed == Inputfield::collapsedNever ? '' : $markup['item_toggle']; 
                if($toggle && strpos($toggle, 'title=') === false) {
                    $toggle = str_replace("class=", "title='" . $this->_('Toggle open/close') . "' class=", $toggle);
                }
                if($skipLabel === Inputfield::skipLabelHeader || $quietMode) {
                    // label only shows when field is collapsed
                    $label = str_replace('{out}', $icon . $label . $toggle, $markup['item_label_hidden']); 
                } else {
                    // label always visible
                    $label = str_replace(array('{for}', '{out}'), array($for, $icon . $label . $toggle), $markup['item_label']); 
                }
                $headerClass = trim($inputfield->getSetting('headerClass') . " $classes[item_label]");
                if($headerClass) {
                    if(strpos($label, '{class}') !== false) {
                        $label = str_replace('{class}', ' ' . $headerClass, $label); 
                    } else {
                        $label = preg_replace('/( class=[\'"][^\'"]+)/', '$1 ' . $headerClass, $label, 1);
                    }
                } else if(strpos($label, '{class}') !== false) {
                    $label = str_replace('{class}', '', $label); 
                }
            } else if($skipLabel === Inputfield::skipLabelMarkup) {
                // no header and no markup for header
                $label = '';
            } else {
                // no header
                // $inputfield->addClass('InputfieldNoHeader', 'wrapClass'); 
            }
            
            $columnWidth = (int) $inputfield->getSetting('columnWidth');
            $columnWidthAdjusted = $columnWidth;
            if($columnWidthSpacing) {
                $columnWidthAdjusted = $columnWidth + ($columnWidthTotal ? -1 * $columnWidthSpacing : 0);
            }
            if($columnWidth >= 9 && $columnWidth <= 100) {
                $ffAttrs['class'] .= ' ' . $classes['item_column_width'];
                if(!$columnWidthTotal) {
                    $ffAttrs['class'] .= ' ' . $classes['item_column_width_first'];
                }
                $columnWidthTotal += $columnWidth;
                if(!$useColumnWidth || $useColumnWidth > 1) {
                    if($columnWidthTotal >= 95 && $columnWidthTotal < 100) {
                        $columnWidthAdjusted += (100 - $columnWidthTotal);
                        $columnWidthTotal = 100;
                    }
                    $ffAttrs['data-colwidth'] = "$columnWidthAdjusted%";
                }
                if($useColumnWidth) {
                    $ffAttrs['style'] = "width: $columnWidthAdjusted%;";
                }
                //if($columnWidthTotal >= 100 && !$requiredIf) $columnWidthTotal = 0; // requiredIf meant to be a showIf?
                if($columnWidthTotal >= 100) $columnWidthTotal = 0;
            } else {
                $columnWidthTotal = 0;
            }
            if(!isset($ffAttrs['id'])) $ffAttrs['id'] = 'wrap_' . $inputfield->attr('id'); 
            $ffAttrs['class'] = str_replace('Inputfield_ ', '', $ffAttrs['class']); 
            $wrapClass = $inputfield->getSetting('wrapClass');
            if($wrapClass) $ffAttrs['class'] .= " " . $wrapClass; 
            foreach($inputfield->wrapAttr() as $k => $v) {
                if(!empty($ffAttrs[$k])) {
                    $ffAttrs[$k] .= " $v";
                } else {
                    $ffAttrs[$k] = $v;
                }
            }
            foreach($ffAttrs as $k => $v) {
                $k = $this->entityEncode($k);
                $v = $this->entityEncode(trim($v));
                $attrs .= " $k='$v'";
            }
            $markupItemContent = $markup['item_content'];
            $contentClass = trim($inputfield->getSetting('contentClass') . " $classes[item_content]");
            if($contentClass) {
                if(strpos($markupItemContent, '{class}') !== false) {
                    $markupItemContent = str_replace('{class}', ' ' . $contentClass, $markupItemContent); 
                } else {
                    $markupItemContent = preg_replace('/( class=[\'"][^\'"]+)/', '$1 ' . $contentClass, $markupItemContent, 1);
                }
            } else if(strpos($markupItemContent, '{class}') !== false) {
                $markupItemContent = str_replace('{class}', '', $markupItemContent); 
            }
            if($inputfield->className() != 'InputfieldWrapper') $ffOut = str_replace('{out}', $ffOut, $markupItemContent); 
            $out .= str_replace(array('{attrs}', '{out}'), array(trim($attrs), $label . $ffOut), $markup['item']); 
            $lastInputfield = $inputfield;
        } // foreach($children as $inputfield)

        if($out) {
            $ulClass = $classes['list'];
            $lastColumnWidth = $lastInputfield ? $lastInputfield->getSetting('columnWidth') : 0;
            if($columnWidthTotal || ($lastInputfield && $lastColumnWidth >= 10 && $lastColumnWidth < 100)) {
                $ulClass .= ' ' . $classes['list_clearfix'];
            }
            $attrs = "class='$ulClass'"; // . ($this->attr('class') ? ' ' . $this->attr('class') : '') . "'";
            if(!($this instanceof InputfieldForm)) {
                foreach($this->getAttributes() as $attr => $value) {
                    if(strpos($attr, 'data-') === 0) $attrs .= " $attr='" . $this->entityEncode($value) . "'";
                }
            }
            $out = $this->attr('value') . str_replace(array('{attrs}', '{out}'), array($attrs, $out), $markup['list']); 
        }

        return $out; 
    }

    /**
     * Render the output of this Inputfield and its children, showing values only (no inputs)
     * 
     * #pw-group-output
     * 
     * @return string
     * 
     */
    public function ___renderValue() {
        if(!count($this->children())) return '';
        $this->addClass('InputfieldRenderValueMode');
        $this->set('renderValueMode', true); 
        $out = $this->render(); 
        $this->set('renderValueMode', false); 
        return $out; 
    }

    /**
     * Render output for an individual Inputfield
     * 
     * This method takes care of all the pre-and-post requisites needed for rendering an Inputfield
     * among a group of Inputfields. It is used by the `InputfieldWrapper::render()` method for each
     * Inputfield present in the children. 
     * 
     * #pw-group-output
     * 
     * @param Inputfield $inputfield The Inputfield to render.
     * @param bool $renderValueMode Specify true if we are only rendering values (default=false).
     * @return string Rendered output
     * 
     */
    public function ___renderInputfield(Inputfield $inputfield, $renderValueMode = false) {

        $inputfieldID = $inputfield->attr('id');
        $collapsed = $inputfield->getSetting('collapsed');
        $ajaxInputfield = $collapsed == Inputfield::collapsedYesAjax || ($collapsed == Inputfield::collapsedBlankAjax && $inputfield->isEmpty());
        $ajaxHiddenInput = "<input type='hidden' name='processInputfieldAjax[]' value='$inputfieldID' />";
        $ajaxID = $this->wire()->config->ajax ? $this->wire()->input->get('renderInputfieldAjax') : '';
        $required = $inputfield->getSetting('required');
        
        if($ajaxInputfield && (($required && $inputfield->isEmpty()) || !$this->wire()->user->isLoggedin())) {
            // if an ajax field is empty, and is required, then we don't use ajax render mode
            // plus, we only allow ajax inputfields for logged-in users
            $ajaxInputfield = false;
            if($collapsed == Inputfield::collapsedYesAjax) $inputfield->collapsed = Inputfield::collapsedYes;
            if($collapsed == Inputfield::collapsedBlankAjax) $inputfield->collapsed = Inputfield::collapsedBlank;
            // indicate to next processInput that this field can be processed
            $inputfield->appendMarkup .= $ajaxHiddenInput;
        }

        $restoreValue = null; // value to restore, if we happen to modify it before render (renderValueMode only)
        
        if($renderValueMode) {
            $flags = $inputfield->getSetting('renderValueFlags');
            $inputfield->addClass('InputfieldRenderValueMode', 'wrapClass');
            if($flags & Inputfield::renderValueMinimal) {
                $inputfield->addClass('InputfieldRenderValueMinimal', 'wrapClass');
            }
            if($flags & Inputfield::renderValueFirst) {
                // render only first item value
                $inputfield->addClass('InputfieldRenderValueFirst', 'wrapClass');
                $value = $inputfield->attr('value');
                if(WireArray::iterable($value) && count($value) > 1) {
                    $restoreValue = $value;
                    if(is_array($value)) {
                        $inputfield->attr('value', array_slice($value, 0, 1));
                    } else if($value instanceof WireArray) {
                        $inputfield->attr('value', $value->slice(0, 1));
                    }
                }
            }
        }
        
        $inputfield->renderReady($this, $renderValueMode);
        
        if($ajaxInputfield) {
            
            if($ajaxID && $ajaxID === $inputfieldID) {
                // render ajax inputfield
                $editable = $inputfield->editable();
                if($renderValueMode || !$editable) {
                    echo $inputfield->renderValue();
                } else {
                    echo $inputfield->render();
                    echo $ajaxHiddenInput;
                }
                exit;
                
            } else if($ajaxID && $ajaxID != $inputfieldID && $inputfield instanceof InputfieldWrapper && 
                $inputfield->getChildByName(str_replace('Inputfield_', '', $ajaxID))) {
                // nested ajax inputfield, within another ajax inputfield
                $in = $inputfield->getChildByName(str_replace('Inputfield_', '', $ajaxID));
                return $this->renderInputfield($in, $renderValueMode);
                
            } else {
                // do not render ajax inputfield now, instead render placeholder
                return $this->renderInputfieldAjaxPlaceholder($inputfield, $renderValueMode);
            }
        }
        
        if(!$renderValueMode && $inputfield->editable()) return $inputfield->render();
    
        // renderValueMode
        $out = $inputfield->renderValue();
        if(!is_null($restoreValue)) {
            $inputfield->attr('value', $restoreValue);
            $inputfield->resetTrackChanges();
        }
        if(is_null($out)) return '';
        if(!strlen($out) && !$inputfield instanceof InputfieldWrapper) $out = '&nbsp;'; // prevent output from being skipped over
        return $out;
    }

    /**
     * Render a placeholder for an ajax-loaded Inputfield
     * 
     * @param Inputfield $inputfield
     * @param bool $renderValueMode
     * @return string
     * 
     */
    protected function renderInputfieldAjaxPlaceholder(Inputfield $inputfield, $renderValueMode) {
    
        $input = $this->wire()->input;
        $sanitizer = $this->wire()->sanitizer;
        $inputfieldID = $inputfield->attr('id');
        $url = $input->url();
        $queryString = $input->queryString();
        
        if(strpos($queryString, 'renderInputfieldAjax=') !== false) {
            // in case nested ajax request 
            $queryString = preg_replace('/&?renderInputfieldAjax=[^&]+/', '', $queryString);
        }
        
        $url .= $queryString ? "?$queryString&" : "?";
        $url .= "renderInputfieldAjax=$inputfieldID";
        $url = $sanitizer->entities($url);
        
        $out = "<div class='renderInputfieldAjax'><input type='hidden' value='$url' /></div>";
        
        if($inputfield instanceof InputfieldWrapper) {
            // load assets they will need
            foreach($inputfield->getAll() as $in) {
                $in->renderReady($inputfield, $renderValueMode);
            }
        }
    
        // ensure that Inputfield::render() hooks are still called
        if($inputfield->hasHook('render()')) {
            $inputfield->runHooks('render', array(), 'before');
        }
        
        return $out; 
    }

    /**
     * Process input for all children
     * 
     * #pw-group-input
     *
     * @param WireInputData $input
     * @return $this
     * 
     */
    public function ___processInput(WireInputData $input) {
    
        if(!$this->children) return $this; 

        foreach($this->children() as $key => $child) {
            /** @var Inputfield $child */

            // skip over the field if it is not processable
            if(!$this->isProcessable($child)) continue;     

            // pass along the dependencies value to child wrappers
            if($child instanceof InputfieldWrapper && $this->getSetting('useDependencies') === false) {
                $child->set('useDependencies', false); 
            }

            // call the inputfield's processInput method
            $child->processInput($input); 

            // check if a value is required and field is empty, trigger an error if so
            if($child->attr('name') && $child->getSetting('required') && $child->isEmpty()) {
                $requiredLabel = $child->getSetting('requiredLabel'); 
                if(empty($requiredLabel)) $requiredLabel = $this->requiredLabel;
                $child->error($requiredLabel); 
            }
        }

        return $this; 
    }

    /**
     * Is the given Inputfield processable for input?
     * 
     * Returns whether or not the given Inputfield should be processed by processInput()
     * 
     * When an `Inputfield` has a `showIf` property, then this returns false, but it queues
     * the field in the delayedChildren array for later processing. The root container should
     * temporarily remove the 'showIf' property of inputfields they want processed. 
     * 
     * #pw-internal
     * 
     * @param Inputfield $inputfield
     * @return bool
     * 
     */
    public function isProcessable(Inputfield $inputfield) {
        
        if(!$inputfield->editable()) return false;
        
        // visibility settings that aren't saveable
        static $skipTypes = array(
            Inputfield::collapsedHidden,
            Inputfield::collapsedLocked,
            Inputfield::collapsedNoLocked,
            Inputfield::collapsedBlankLocked,
            Inputfield::collapsedYesLocked
        );
        $collapsed = (int) $inputfield->getSetting('collapsed');
        if(in_array($collapsed, $skipTypes)) return false;

        if(in_array($collapsed, array(Inputfield::collapsedYesAjax, Inputfield::collapsedBlankAjax))) {
            $processAjax = $this->wire()->input->post('processInputfieldAjax');
            if(is_array($processAjax) && in_array($inputfield->attr('id'), $processAjax)) {
                // field can be processed (convention used by InputfieldWrapper)
            } else if($collapsed == Inputfield::collapsedBlankAjax && !$inputfield->isEmpty()) {
                // field can be processed because it is only collapsed if blank
            } else if(isset($_SERVER['HTTP_X_FIELDNAME']) && $_SERVER['HTTP_X_FIELDNAME'] === $inputfield->attr('name')) {
                // field can be processed (convention used by ajax uploaded file and other ajax types)
            } else {
                // field was not rendered via ajax and thus can't be processed
                return false;
            }
            unset($processAjax);
        }

        // if dependencies aren't in use, we can skip the rest
        if($this->getSetting('useDependencies') === false) return true; 
        
        if(strlen($inputfield->getSetting('showIf')) || 
            ($inputfield->getSetting('required') && strlen($inputfield->getSetting('requiredIf')))) {
            
            $name = $inputfield->attr('name'); 
            if(!$name) {
                $name = $inputfield->attr('id'); 
                if(!$name) $name = $this->wire()->sanitizer->fieldName($inputfield->getSetting('label')); 
                $inputfield->attr('name', $name); 
            }
            $this->delayedChildren[$name] = $inputfield; 
            return false;
        }
        
        return true;
    }

    /**
     * Returns true if all children are empty, or false if one or more is populated
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @return bool
     * 
     */
    public function isEmpty() {
        $empty = true; 
        foreach($this->children() as $child) {
            if(!$child->isEmpty()) {
                $empty = false;
                break;
            }
        }
        return $empty;
    }

    /**
     * Return Inputfields in this wrapper that are required and have empty values
     *
     * This method includes all children up through the tree, not just direct children.
     *
     * #pw-internal
     *
     * @param bool $required Only include empty Inputfields that are required? (default=true)
     * @return array of Inputfield instances indexed by name attributes
     *
     */
    public function getEmpty($required = true) {
        $a = array();
        static $n = 0;
        foreach($this->children() as $child) {
            if($child instanceof InputfieldWrapper) {
                $a = array_merge($a, $child->getEmpty($required));
            } else {
                if($required && !$child->getSetting('required')) continue;
                if(!$child->isEmpty()) continue;
                $name = $child->attr('name');
                if(empty($name)) $name = "_unknown" . (++$n);
                $a[$name] = $child;
            }
        }
        return $a;
    }

    /**
     * Return an array of errors that occurred on any of the children during input processing.
     *
     * Should only be called after `InputfieldWrapper::processInput()`.
     * 
     * #pw-group-input
     * #pw-group-retrieval-and-traversal
     *
     * @param bool $clear Specify true to clear out the errors (default=false).
     * @return array Array of error strings
     *
     */
    public function getErrors($clear = false) {
        $errors = parent::getErrors($clear); 
        foreach($this->children() as $key => $child) {
            foreach($child->getErrors($clear) as $e) {
                $label = $child->getSetting('label');
                $msg = $label ? $label : $child->attr('name'); 
                $errors[] = $msg . " - $e";
            }
        }
        return $errors;
    }

    /**
     * Return all children Inputfield objects
     * 
     * #pw-group-retrieval-and-traversal
     *  
     * @param string $selector Optional selector string to filter the children by
     * @return InputfieldsArray
     *
     */
    public function children($selector = '') {
        if($selector) {
            return $this->children->find($selector);
        } else {
            return $this->children;
        }
    }

    /**
     * Find an Inputfield below this one that has the given name
     * 
     * This is an alternative to the `getChildByName()` method, with more options for when you need it. 
     * For instance, it can also accept a selector string or numeric index for the $name argument, and you
     * can optionally disable the $recursive behavior. 
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @param string|int $name Name or selector string of child to find, omit for first child, or specify zero-based index of child to return.
     * @param bool $recursive Find child recursively? Looks for child in this wrapper, and all other wrappers below it. (default=true)
     * @return Inputfield|null Returns Inputfield instance if found, or null if not.
     * @since 3.0.110
     * 
     */
    public function child($name = '', $recursive = true) {
        $child = null;
        $children = $this->children();

        if(!$children->count()) {
            // no child possible

        } else if(empty($name)) {
            // first child
            $child = $children->first();
            
        } else if(is_int($name)) {
            // number index
            $child = $children->eq($name);
            
        } else if($this->wire()->sanitizer->name($name) === $name) {
            // child by name
            $wrappers = array();
            foreach($children as $f) {
                if($f->getAttribute('name') === $name) {
                    $child = $f;
                    break;
                } else if($recursive && $f instanceof InputfieldWrapper) {
                    $wrappers[] = $f;
                }
            }
            if(!$child && $recursive && count($wrappers)) {
                foreach($wrappers as $wrapper) {
                    $child = $wrapper->child($name, $recursive);
                    if($child) break;
                }
            }

        } else if(Selectors::stringHasSelector($name)) {
            // first child matching selector string
            $child = $children->find("$name, limit=1")->first();
        }
        
        return $child;
    }

    /**
     * Return all children Inputfields (alias of children method)
     *
     * #pw-internal
     *
     * @param string $selector Optional selector string to filter the children by
     * @return InputfieldsArray
     *
     */
    public function getChildren($selector = '') {
        return $this->children($selector); 
    }

    /**
     * Return array of inputfields (indexed by name) of fields that had dependencies and were not processed
     * 
     * The results are to be handled by the root containing element (i.e. InputfieldForm).
     * 
     * #pw-internal
     *
     * @param bool $clear Set to true in order to clear the delayed children list.
     * @return array|Inputfield[]
     *
     */
    public function _getDelayedChildren($clear = false) {
        $a = $this->delayedChildren; 
        foreach($this->children() as $child) {
            if(!$child instanceof InputfieldWrapper) continue; 
            $a = array_merge($a, $child->_getDelayedChildren($clear)); 
        }
        if($clear) $this->delayedChildren = array();
        return $a; 
    }

    /**
     * Find all children Inputfields matching a selector string
     * 
     * #pw-group-retrieval-and-traversal
     *
     * @param string $selector Required selector string to filter the children by
     * @return InputfieldsArray
     *
     */
    public function find($selector) {
        return $this->children()->find($selector); 
    }

    /**
     * Given an Inputfield name, return the child Inputfield or NULL if not found.
     * 
     * This traverses all children recursively to find the requested Inputfield. 
     * 
     * This is the same as the `InputfieldWrapper::get()` method except that it can
     * only return Inputfield or null, and has no crossover with other settings, 
     * properties or API variables. 
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @param string $name Name of Inputfield
     * @return Inputfield|InputfieldWrapper|null
     * @see InputfieldWrapper::get(), InputfieldWrapper::children()
     *
     */
    public function getChildByName($name) {
        return strlen($name) ? $this->getByAttr('name', $name) : null;
    }

    /**
     * Shorter alias of getChildByName()
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @param string $name
     * @return Inputfield|null
     * @since 3.0.172
     * 
     */
    public function getByName($name) {
        return strlen($name) ? $this->getByAttr('name', $name) : null;
    }

    /**
     * Given an attribute name and value, return the first matching Inputfield or null if not found
     * 
     * This traverses all children recursively to find the requested Inputfield.
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @param string $attrName Attribute to match, such as 'id', 'name', 'value', etc.
     * @param string $attrValue Attribute value to match
     * @return Inputfield|null
     * @since 3.0.196
     * 
     */
    public function getByAttr($attrName, $attrValue) {
        $inputfield = null;
        foreach($this->children() as $child) {
            if($child->getAttribute($attrName) === $attrValue) {
                $inputfield = $child;
            } else if($child instanceof InputfieldWrapper) {
                $inputfield = $child->getByAttr($attrName, $attrValue);
            }
            if($inputfield) break;
        }
        return $inputfield;
    }

    /**
     * Get value of Inputfield by name
     * 
     * This traverses all children recursively to find the requested Inputfield,
     * and get the value attribute from it. A value of null is returned if the 
     * Inputfield cannot be found. 
     * 
     * @param string $name
     * @return string|int|float|bool|array|object|null
     * @since 3.0.172
     * 
     */
    public function getValueByName($name) {
        $inputfield = $this->getByName($name);
        return $inputfield ? $inputfield->val() : null;
    }

    /**
     * Enables foreach() of the children of this class
     * 
     * Per the InteratorAggregate interface, make the Inputfield children iterable.
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @return InputfieldsArray
     *
     */
    #[\ReturnTypeWillChange] 
    public function getIterator() {
        return $this->children(); 
    }

    /**
     * Return the quantity of children present
     * 
     * #pw-group-retrieval-and-traversal
     * 
     * @return int
     *
     */
    #[\ReturnTypeWillChange] 
    public function count() {
        return count($this->children());
    }

    /**
     * Get all Inputfields below this recursively in a flat InputfieldWrapper (children, and their children, etc.)
     *
     * Note that all InputfieldWrapper instances are removed as a result (except for the containing InputfieldWrapper).
     * 
     * #pw-group-retrieval-and-traversal
     *  
     * @param array $options Options to modify behavior (3.0.169+)
     *  - `withWrappers` (bool): Also include InputfieldWrapper objects? (default=false) 3.0.169+
     * @return InputfieldWrapper|InputfieldsArray
     *
     */
    public function getAll(array $options = array()) {
        /** @var InputfieldsArray $all */
        $all = $this->wire(new InputfieldsArray());
        foreach($this->children() as $child) {
            if($child instanceof InputfieldWrapper) {
                if(!empty($options['withWrappers'])) $all->add($child);
                foreach($child->getAll($options) as $c) {
                    $all->add($c); 
                }
            } else {
                $all->add($child); 
            }
        }
        return $all;
    }
    
    /**
     * Start or stop tracking changes, applying the same to any children
     * 
     * #pw-internal
     * 
     * @param bool $trackChanges
     * @return Inputfield|InputfieldWrapper
     *
     */
    public function setTrackChanges($trackChanges = true) {
        $children = $this->children();
        if(count($children)) foreach($children as $child) $child->setTrackChanges($trackChanges); 
        return parent::setTrackChanges($trackChanges); 
    }

    /**
     * Start or stop tracking changes after clearing out any existing tracked changes, applying the same to any children
     * 
     * #pw-internal
     *
     * @param bool $trackChanges
     * @return Inputfield|InputfieldWrapper
     *
     */
    public function resetTrackChanges($trackChanges = true) {
        $children = $this->children();
        if(count($children)) foreach($children as $child) $child->resetTrackChanges($trackChanges); 
        return parent::resetTrackChanges($trackChanges);
    }

    /**
     * Get configuration Inputfields for this InputfieldWrapper
     * 
     * #pw-group-module
     * 
     * @return InputfieldWrapper
     *
     */
    public function ___getConfigInputfields() {

        $inputfields = parent::___getConfigInputfields();

        /** @var InputfieldSelect $f */
        $f = $inputfields->getChildByName('collapsed');
        if($f) {
            // remove all options for 'collapsed' except collapsedYes and collapsedNo
            foreach($f->getOptions() as $value => $label) {
                if(!in_array($value, array(Inputfield::collapsedNo, Inputfield::collapsedYes))) {
                    $f->removeOption($value);
                }
            }
        }

        return $inputfields;
    }

    /**
     * Set custom markup for render, see self::$markup at top for reference.
     * 
     * #pw-internal
     *
     * @param array $markup
     *
     */
    public static function setMarkup(array $markup) { 
        self::$markup = array_merge(self::$markup, $markup); 
    }

    /**
     * Get custom markup for render, see self::$markup at top for reference.
     * 
     * #pw-internal
     *
     * @return array 
     *
     */
    public static function getMarkup() { 
        return array_merge(self::$defaultMarkup, self::$markup); 
    }

    /**
     * Set custom classes for render, see self::$classes at top for reference.
     * 
     * #pw-internal
     * 
     * @param array $classes
     *
     */
    public static function setClasses(array $classes) { 
        self::$classes = array_merge(self::$classes, $classes); 
    }

    /**
     * Get custom classes for render, see self::$classes at top for reference.
     * 
     * #pw-internal
     *
     * @return array
     * 
     */
    public static function getClasses() { 
        return array_merge(self::$defaultClasses, self::$classes); 
    }

    /**
     * Import an array of Inputfield definitions to to this InputfieldWrapper instance
     *
     * Your array should be an array of associative arrays, with each element describing an Inputfield.
     * The following properties are required for each Inputfield definition: 
     * 
     * - `type` Which Inputfield module to use (may optionally exclude the "Inputfield" prefix). 
     * - `name` Name attribute to use for the Inputfield. 
     * - `label` Text label that appears above the Inputfield. 
     * 
     * ~~~~~
     * // Example array for Inputfield definitions
     * array(
     *   array(
     *     'name' => 'fullname',
     *     'type' => 'text',
     *     'label' => 'Field label'
     *     'columnWidth' => 50,
     *     'required' => true,
     *   ),
     *   array(
     *     'name' => 'color',
     *     'type' => 'select',
     *     'label' => 'Your favorite color',
     *     'description' => 'Select your favorite color or leave blank if you do not have one.',
     *     'columnWidth' => 50,
     *     'options' => array(
     *        'red' => 'Brilliant Red',
     *        'orange' => 'Citrus Orange',
     *        'blue' => 'Sky Blue'
     *     )
     *   ),
     *   // alternative usage: associative array where name attribute is specified as key
     *   'my_fieldset' => array(
     *     'type' => 'fieldset',
     *     'label' => 'My Fieldset',
     *     'children' => array(
     *       'some_field' => array(
     *         'type' => 'text',
     *         'label' => 'Some Field',
     *       )
     *     )
     * );
     * // Note: you may alternatively use associative arrays where the keys are assumed to 
     * // be the 'name' attribute.See the last item 'my_fieldset' above for an example. 
     * ~~~~~
     * 
     * #pw-group-manipulation
     *
     * @param array $a Array of Inputfield definitions
     * @param InputfieldWrapper $inputfields Specify the wrapper you want them added to, or omit to use current.
     * @return $this
     *
     */
    public function importArray(array $a, InputfieldWrapper $inputfields = null) {
        
        $modules = $this->wire()->modules;
        
        if(is_null($inputfields)) $inputfields = $this; 
        if(!count($a)) return $inputfields;
    
        // if just a single field definition rather than an array of them, normalize to array of array
        $first = reset($a); 
        if(!is_array($first)) $a = array($a); 
        
        foreach($a as $name => $info) {

            if(isset($info['name'])) {
                $name = $info['name'];
                unset($info['name']);
            }

            if(!isset($info['type'])) {
                $this->error("Skipped field '$name' because no 'type' is set");
                continue;
            }

            $type = $info['type'];
            unset($info['type']);
            if(strpos($type, 'Inputfield') !== 0) $type = "Inputfield" . ucfirst($type);
            
            /** @var Inputfield $f */
            $f = $modules->get($type);

            if(!$f) {
                $this->error("Skipped field '$name' because module '$type' does not exist");
                continue;
            }
            
            $f->attr('name', $name);
            
            if($type === 'InputfieldCheckbox') {
                // checkbox behaves a little differently, just like in HTML
                /** @var InputfieldCheckbox $f */
                if(!empty($info['attr']['value'])) {
                    $f->attr('value', $info['attr']['value']);
                } else if(!empty($info['value'])) {
                    $f->attr('value', $info['value']);
                }
                unset($info['attr']['value'], $info['value']);
                $f->autocheck = 1; // future value attr set triggers checked state
            }

            if(isset($info['attr']) && is_array($info['attr'])) {
                foreach($info['attr'] as $key => $value) {
                    $f->attr($key, $value);
                }
                unset($a['attr']);
            }

            foreach($info as $key => $value) {
                if($key == 'children') continue;
                $f->$key = $value;
            }

            if($f instanceof InputfieldWrapper && !empty($info['children'])) {
                $this->importArray($info['children'], $f);
            }

            $inputfields->add($f);
        }

        return $inputfields;
    }

    /**
     * Populate values for all Inputfields in this wrapper from the given $data object or array.
     * 
     * This iterates through every field in this InputfieldWrapper and looks for field names 
     * that are also present in the given object or array. If present, it uses them to populate
     * the associated Inputfield. 
     * 
     * If given an array, it should be an associative with the field 'name' as the keys and
     * the field 'value' as the array value, i.e. `['field_name' => 'field_value']`.
     * 
     * #pw-group-manipulation
     * 
     * @param WireData|Wire|ConfigurableModule|array $data
     * @return array Returns array of field names that were populated
     * 
     */
    public function populateValues($data) {
        $populated = array();
        foreach($this->getAll() as $inputfield) {
            if($inputfield instanceof InputfieldWrapper) continue; 
            $name = $inputfield->attr('name');
            if(!$name) continue;
            $value = null;
            if(is_array($data)) {
                // array
                $value = isset($data[$name]) ? $data[$name] : null;
            } else if($data instanceof WireData) {
                // WireData object
                $value = $data->data($name);
            } else if(is_object($data)) {
                // Wire or other object with __get() implemented
                $value = $data->$name;
            } 
            if($value === null) continue;
            if($inputfield instanceof InputfieldCheckbox) $inputfield->autocheck = 1; 
            $inputfield->attr('value', $value);
            $populated[$name] = $name;
        }
        return $populated;
    }

    /**
     * Get an array of all family below this (recursively) for debugging purposes
     * 
     * #pw-internal
     * 
     * @return array
     * 
     */
    public function debugMap() {
        $a = array();
        foreach($this as $in) {
            $info = array(
                'id' => $in->id, 
                'name' => $in->name, 
                'type' => $in->className(), 
            );
            if($in instanceof InputfieldWrapper) {
                $info['children'] = $in->debugMap();
            }
            $a[] = $info;
        }
        return $a;
    }

    /**
     * Debug info
     * 
     * #pw-internal
     * 
     * @return array
     * 
     */
    public function __debugInfo() {
        $info = parent::__debugInfo();
        $info['children'] = $this->debugMap();
        return $info;
    }
    
}