Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** Base for selection form inputs, which by default behaves as a regular <select>** Serves as the base for Inputfields that provide selection of options (whether single or multi).* As a result, this class includes functionality for, and checks for both single-and-multi selection values.* Sublcasses will want to override the render method, but it's not necessary to override processInput().* Subclasses that select multiple values should implement the InputfieldHasArrayValue interface.** ProcessWire 3.x, Copyright 2022 by Ryan Cramer* https://processwire.com** @property string|int $defaultValue* @property array|string $options Get or set options, array of [value => label], or use options string.* @property array $optionAttributes* @property bool $valueAddOption If value attr set from API (only) that is not an option, add it as an option? (default=false) 3.0.171+**/class InputfieldSelect extends Inputfield implements InputfieldHasSelectableOptions {/*** Options specific to this Select**/protected $options = array();/*** Attributes for options specific to this select (if applicable)**/protected $optionAttributes = array();/*** Alternate language labels for options, array of [ languageID => [ optionValue => optionLabel ] ]** @var array**/protected $optionLanguageLabels = array();/*** Return information about this module**/public static function getModuleInfo() {return array('title' => __('Select', __FILE__), // Module Title'summary' => __('Selection of a single value from a select pulldown', __FILE__), // Module Summary'version' => 102,'permanent' => true,);}/*** Construct**/public function __construct() {parent::__construct();$this->set('defaultValue', '');$this->set('valueAddOption', false);}/*** Add an option that may be selected** If you want to add an optgroup, use the $value param as the label, and the label param as an array of options.* Note that optgroups may not be applicable to other Inputfields that descend from InputfieldSelect.** @param string $value Value that the option submits (or label of optgroup, if specifying an optgroup)* @param string $label|array Optional label associated with the value (if null, value will be used as the label), or array of optgroup options [value=>label]* @param array $attributes Optional attributes to be associated with this option (i.e. a 'selected' attribute for an <option> tag)* @return $this**/public function addOption($value, $label = null, array $attributes = null) {if(is_null($label) || (is_string($label) && !strlen($label))) $label = $value;if(isset($this->options[$value])) unset($this->options[$value]);$this->options[$value] = $label;if(!is_null($attributes)) $this->optionAttributes[$value] = $attributes;return $this;}/*** Add selectable option with label, optionally for specific language** @param string|int $value* @param string $label* @param Language|null $language* @return $this* @since 3.0.176**/public function addOptionLabel($value, $label, $language = null) {$this->optionLanguageLabel($language, $value, $label);return $this;}/*** Add multiple options at once** @param array $options Array of options to add. It is assumed that array keys are the option value, and array* values are the option labels, unless overridden by the $assoc argument.* @param bool $assoc Is $options an associative array? (default=true). Specify false if $options is intended to be* a regular PHP array, where the array keys/indexes should be ignored, and option value will also be the label.* @return $this**/public function addOptions(array $options, $assoc = true) {foreach($options as $k => $v) {if($assoc) {$this->addOption($k, $v);} else {$this->addOption($v);}}return $this;}/*** Set/replace all options** @param array $options Array of options to add. It is assumed that array keys are the option value, and array* values are the option labels, unless overridden by the $assoc argument.* @param bool $assoc Is $options an associative array? (default=true). Specify false if $options is intended to be* a regular PHP array, where the array keys/indexes should be ignored, and option value will also be the label.* @return $this**/public function setOptions(array $options, $assoc = true) {$this->options = array();return $this->addOptions($options, $assoc);}/*** Get or set label for given option value/key (default language)** @param string|int $key Option value to get or set label for* @param string|null $label If setting label, specify label to set. If getting, then omit.* @return string|bool Returns boolean false if option not found, otherwise returns option label (string).* @since 3.0.134* @see InputfieldSelect::optionLanguageLabel()**/public function optionLabel($key, $label = null) {if(!isset($this->options[$key])) return false;if($label !== null) $this->options[$key] = $label;return isset($this->options[$key]) ? $this->options[$key] : null;}/*** Get or set alternative language label(s)** @param Language|int|string $language Language object, id or name (required).* @param string|null|bool $key Option key/value to get/set label for,* OR omit to return all currently set option language labels for language,* OR boolean false to remove all language labels for this option value/key.* OR array of [ optionValue => optionLabel ] to add multiple option values for language.* @param $label|string|bool Translated label text to set,* OR omit to GET language label.* OR boolean false to remove.* @return string|array|Inputfield Return value depends on given arguments**/public function optionLanguageLabel($language, $key = null, $label = null) {$languages = $this->wire()->languages;if(!$languages) return $this;if(is_string($language) && !ctype_digit("$language")) {$language = $languages->get($language);}$languageID = (int) "$language"; // converts Page or string to idif(!isset($this->optionLanguageLabels[$languageID])) {$this->optionLanguageLabels[$languageID] = array();}if($key === null) {return $this->optionLanguageLabels[$languageID];} else if($key === false) {unset($this->optionLanguageLabels[$languageID]);} else if(is_array($key)) {foreach($key as $k => $v) $this->optionLanguageLabels[$languageID][$k] = $v;} else if($label === null) {return isset($this->optionLanguageLabels[$languageID][$key]) ? $this->optionLanguageLabels[$languageID][$key] : '';} else if($label === false) {unset($this->optionLanguageLabels[$languageID][$key]);} else {$this->optionLanguageLabels[$languageID][$key] = $label;}return $this;}/*** Given a multi-line string, convert it to options, one per line** Lines preceded with a plus "+" are assumed selected, i.e. +option* Lines with an equals sign are split into separate value and label, i.e. value=label** @param string $value* @return $this**/public function addOptionsString($value) {$value = (string) $value;$options = explode("\n", $value);$lastOption = '';$optgroup = array();$optgroupLabel = '';foreach($options as $option) {// in an optgroup when line starts with 3 or more spacesif(strpos($option, ' ') === 0 && !empty($lastOption)) {// if no optgroupLabel, we're starting a new option groupif(empty($optgroupLabel)) $optgroupLabel = $lastOption;$option = trim($option);} else {if($optgroupLabel) $this->addOption($optgroupLabel, $optgroup);$optgroup = array();$optgroupLabel = '';}$option = trim($option);$attrs = array();$label = null;if(strpos($option, '++') === 0) {// double plus should convert to single plus and not make it selected$option = substr($option, 1);} else if(substr($option, 0, 1) === '+') {// if option starts with a plus then make it selected$attrs['selected'] = 'selected';$option = ltrim($option, '+');} else if(strpos($option, 'disabled:') === 0) {// if option starts with "disabled:" then make it disabled$attrs['disabled'] = 'disabled';$option = preg_replace('/^disabled:\s*/', '', $option);}if(strpos($option, '=') !== false && strpos($option, '==') === false) {// option has an equals "=", but not "==", then assume it's a: value=labellist($option, $label) = explode('=', $option);}if(strpos($option, '==') !== false) {// convert double equals "==" to single equals "=", as a means of allowing escaped equals sign$option = str_replace('==', '=', $option);}$option = trim($option, '+ ');if($optgroupLabel) {// add option to optgroup$optgroup[$option] = is_null($label) ? $option : $label;if(count($attrs)) $this->optionAttributes[$option] = $attrs;} else {// add the option$this->addOption($option, $label, $attrs);}$lastOption = $option;}if($optgroupLabel && count($optgroup)) {$this->addOption($optgroupLabel, $optgroup);}return $this;}/*** Add/modify existing option labels from a line separated key=value string, primarily for multi-language support** @param string $str String of optionValue=optionLabel with each on its own line* @param int $languageID Language ID to set for, or omit for default language**/protected function addOptionLabelsString($str, $languageID = 0) {foreach(explode("\n", $str) as $line) {$line = trim($line);$line = ltrim($line, '+');if(strpos($line, 'disabled:') === 0) list(,$line) = explode('disabled:', $line, 2);if(!strpos($line, '=')) continue; // 0 or false OKlist($key, $label) = explode('=', $line, 2);if($languageID) {$this->optionLanguageLabel($languageID, $key, $label);} else {$this->addOption($key, $label);}}}/*** Remove the option with the given value** @param string|int $value* @return $this**/public function removeOption($value) {unset($this->options[$value]);return $this;}/*** Replace an option already present with the new value (and optionally new label and attributes)** @param string|int|float $oldValue* @param string|int|float $newValue* @param string|null $newLabel Specify string to replace or omit (null) to leave existing label* @param array|null $newAttributes Specify array to replace, or omit (null) to leave existing attributes* @return bool True if option was replaced, false if oldValue was not found to replace,* @since 3.0.134**/public function replaceOption($oldValue, $newValue, $newLabel = null, $newAttributes = null) {$options = array();$found = false;foreach($this->options as $value => $label) {if($value === $oldValue) {$found = true;$options[$newValue] = ($newLabel === null ? $label : $newLabel);$attributes = is_array($newAttributes) ? $newAttributes : $this->getOptionAttributes($oldValue);unset($this->optionAttributes[$oldValue], $this->optionAttributes[$newValue]);if(!empty($attributes)) $this->setOptionAttributes($newValue, $attributes);} else {$options[$value] = $label;}}if($found) $this->options = $options;return $found;}/*** Insert options before or after existing option** @param array $options New options to insert [ value => label ]* @param string|int|null $existingValue Insert before or after option having this value* @param bool $insertAfter Insert after rather than before? (default=false)* @return self* @since 3.0.134**/protected function insertOptions(array $options, $existingValue = null, $insertAfter = false) {$a = array();if($existingValue === null || !isset($this->options[$existingValue])) {// existing value isn’t present, so we will prepend or append insteadif($insertAfter) {// append new options to end and return$this->addOptions($options);return $this;} else {// prepend to beginning$a = $options;}}foreach($this->options as $value => $label) {if($value !== $existingValue) {if(!isset($a[$value])) $a[$value] = $label;continue;}// if inserting after, new options will be inserted after this existing optionif($insertAfter) $a[$value] = $label;// add the new optionsforeach($options as $k => $v) {if(isset($a[$k])) unset($a[$k]);$a[$k] = $v;}// add existing option back, after the new onesif(!$insertAfter && !isset($a[$value])) $a[$value] = $label;}$this->options = $a;return $this;}/*** Insert new options before an existing option (or prepend options to beginning)** @param array $options Associative array of `[ 'value' => 'label' ]` containing new options to add.* @param string|int|null $existingValue Existing option value to add options before, or omit to add at beginning.* @return self* @since 3.0.134**/public function insertOptionsBefore(array $options, $existingValue = null) {return $this->insertOptions($options, $existingValue, false);}/*** Insert new options after an existing option** @param array $options Associative array of `[ 'value' => 'label' ]` containing new options to add.* @param string|int|null $existingValue Existing option value to add options after, or omit to append at end.* @return self* @since 3.0.134**/public function insertOptionsAfter(array $options, $existingValue = null) {return $this->insertOptions($options, $existingValue, true);}/*** Get all options for this Select** @return array**/public function getOptions() {return $this->options;}/*** Returns whether the provided value is one of the available options** @param string|int $value* @param array $options Array of options to check, or omit if using this classes options.* @return bool**/public function isOption($value, array $options = null) {if(is_null($options)) $options = $this->options;$is = false;foreach($options as $key => $option) {if(is_array($option)) {// fieldgroupif($this->isOption($value, $option)) {$is = true;break;}} else {if("$value" === "$key") {$is = true;break;}}}return $is;}/*** Returns whether the provided value is selected** @param string|int $value* @return bool**/public function isOptionSelected($value) {$valueAttr = $this->attr('value');if($this->isEmpty()) {// no value set yet, check if it's set in any of the option attributes$selected = false;if(isset($this->optionAttributes[$value])) {$attrs = $this->optionAttributes[$value];if(!empty($attrs['selected']) || !empty($attrs['checked'])) $selected = true;}if($selected) return true;}if($this instanceof InputfieldHasArrayValue) {// multiple selection$selected = false;foreach($valueAttr as $v) {$selected = "$v" === "$value";if($selected) break;}return $selected;}return "$value" == (string) $this->value;}/*** Is the given option value disabled?** @param $value* @return bool**/public function isOptionDisabled($value) {$disabled = false;if(isset($this->optionAttributes[$value])) {$attrs = $this->optionAttributes[$value];if(!empty($attrs['disabled'])) $disabled = true;}return $disabled;}/*** Get or set option attributes** This method is a combined getOptionAttributes() and setOptionAttributes(). Use the dedicated get/set* methods when you need more options.** @param string|int $key Option value to get or set attributes for, or omit to get all option attributes.* @param array|null|bool $attributes Specify array to set attributes, omit to get attributes* @param bool $append Specify true to append to existing attributes rather than replacing* @return array Associative array of attributes* @since 3.0.134**/public function optionAttributes($key = null, $attributes = null, $append = false) {if($key === null) return $this->optionAttributes;if(is_array($attributes)) {if($append) {$this->addOptionAttributes($key, $attributes);} else {$this->setOptionAttributes($key, $attributes);}}return $this->getOptionAttributes($key);}/*** Get an attributes array intended for an item (or for all items)** @param string|int|null $key Option value, or omit to return ALL option attributes indexed by option value* @return array Array of attributes**/public function getOptionAttributes($key = null) {if($key === null) return $this->optionAttributes;if(!isset($this->optionAttributes[$key])) return array();return $this->optionAttributes[$key];}/*** Set/replace entire attributes array for an item** @param string|int|array $key Option value, or specify associative array (indexed by option value) to set ALL option attributes* @param array $attrs Array of attributes to set, or omit if you specified array for first argument.* @return $this**/public function setOptionAttributes($key, array $attrs = array()) {if(is_array($key)) {$this->optionAttributes = $key;} else {$this->optionAttributes[$key] = $attrs;}return $this;}/*** Add attributes for an item (without removing existing attributes), or for multiple items** @param string|int|array $key Option value, or array of option attributes indexed by option value.* @param array $attrs Array of attributes to set, or omit if you specified array for first argument.* @return $this**/public function addOptionAttributes($key, array $attrs = array()) {if(is_array($key)) {foreach($key as $k => $v) {$this->addOptionAttributes($k, $v);}} else {$value = isset($this->optionAttributes[$key]) ? $this->optionAttributes[$key] : array();$this->optionAttributes[$key] = array_merge($value, $attrs);}return $this;}/*** Get an attributes string intended for the <option> element** @param string|array $key If given an array, it will be assumed to the attributes you want rendered.* If given a value for an existing option, then the attributes for that option will be rendered.* @return string**/public function getOptionAttributesString($key) {if(is_array($key)) {$attrs = $key;} else if(!isset($this->optionAttributes[$key])) {return '';} else {$attrs = $this->optionAttributes[$key];}return $this->getAttributesString($attrs);}/*** Render the given options** Note: method was protected prior to 3.0.116** @param array|null $options Options array or omit (null) to use already specified options* @param bool $allowBlank Allow first item to be blank when supported? (default=true)* @return string**/public function renderOptions($options = null, $allowBlank = true) {if($options === null) $options = $this->options;$out = '';reset($options);$key = key($options);$hasBlankOption = empty($key);if($allowBlank && !$hasBlankOption && !$this->attr('multiple')) {if($this->getSetting('required') && $this->attr('value')) {// if required and a value is already selected, do not add a blank option} else {$out .= "<option value=''> </option>";}}foreach($options as $value => $label) {if(is_array($label)) {$out .="<optgroup label='" . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . "'>" .$this->renderOptions($label, false) ."</optgroup>";continue;}$selected = $this->isOptionSelected($value) ? " selected='selected'" : '';$attrs = $this->getOptionAttributes($value);unset($attrs['selected'], $attrs['checked'], $attrs['value']);$attrs = $this->getOptionAttributesString($attrs);$out .="<option$selected $attrs value='" . htmlspecialchars($value, ENT_QUOTES, "UTF-8") . "'>" .$this->entityEncode($label) ."</option>";}return $out;}/*** Check for default value and populate when appropriate** This should be called at the beginning of render() and at the end of processInput()**/protected function checkDefaultValue() {if(!$this->required || !$this->defaultValue || !$this->isEmpty()) return;// when a value is required and the value is empty and a default value is specified, we use it.if($this instanceof InputfieldHasArrayValue) {/** @var InputfieldSelect $this */$value = explode("\n", $this->defaultValue);foreach($value as $k => $v) {$value[$k] = trim($v); // remove possible extra LF}} else {$value = $this->defaultValue;$pos = strpos($value, "\n");if($pos) $value = substr($value, 0, $pos);$value = trim($value);}$this->attr('value', $value);}/*** Render ready** @param Inputfield|null $parent* @param bool $renderValueMode* @return bool**/public function renderReady(Inputfield $parent = null, $renderValueMode = false) {if(!empty($this->optionLanguageLabels) && $this->hasFieldtype === false) {$languages = $this->wire()->languages;if($languages) {// make option labels use use language where available$language = $this->wire()->user->language;$defaultLanguage = $languages->getDefault();if(!empty($this->optionLanguageLabels[$language->id])) {$labels = $this->optionLanguageLabels[$language->id];foreach($this->options as $key => $defaultLabel) {if(empty($labels[$key])) continue;$this->options[$key] = $labels[$key];if($language->id != $defaultLanguage->id) {$this->optionLanguageLabel($defaultLanguage, $key, $defaultLabel);}}}}}return parent::renderReady($parent, $renderValueMode);}/*** Render and return the output for this Select** @return string**/public function ___render() {$this->checkDefaultValue();$attrs = $this->getAttributes();unset($attrs['value']);return"<select " . $this->getAttributesString($attrs) . ">" .$this->renderOptions($this->options) ."</select>";}/*** Render non-editable value** @return string**/public function ___renderValue() {$out = '';$sanitizer = $this->wire()->sanitizer;foreach($this->options as $value => $label) {$o = '';if(is_array($label)) {foreach($label as $k => $v) {if($this->isOptionSelected($k)) {$o = trim($value, ' :') . ": $v";}}} else {if($this->isOptionSelected($value)) $o = $label;}if(strlen($o)) {$out .= "<li>" . $sanitizer->entities($o) . "</li>";}}if(strlen($out)) {$out = "<ul class='pw-bullets'>$out</ul>";}return $out;}/*** Process input from the provided array** In this case we're having the Inputfield base process the input and we're going back and validating the value.* If the value(s) that were set aren't in our specific list of options, we remove them. This is a security measure.** @param WireInputData $input* @return $this**/public function ___processInput(WireInputData $input) {// disable valueAddOption temporarily to prevent it from applying to user input$valueAddOption = $this->valueAddOption;if($valueAddOption) $this->valueAddOption = false;parent::___processInput($input);$name = $this->attr('name');if(!isset($input[$name])) {$value = $this instanceof InputfieldHasArrayValue ? array() : null;$this->setAttribute('value', $value);return $this;}// validate that the selected posted option(s) are those from our options list// removing any that aren't$value = $this->attr('value');if($this instanceof InputfieldHasArrayValue) {/** @var InputfieldSelect $this */if(!is_array($value)) $value = array();foreach($value as $k => $v) {if(!$this->isOption($v)) {unset($value[$k]); // remove invalid option}}} else if($value && !$this->isOption($value)) {$value = null;}$this->setAttribute('value', $value);$this->checkDefaultValue();if($valueAddOption) $this->valueAddOption = $valueAddOption;return $this;}/*** Get property** @param string $key* @return array|mixed|null**/public function get($key) {if($key === 'options') return $this->options;if($key === 'optionAttributes') return $this->optionAttributes;return parent::get($key);}/*** Set property** @param string $key* @param mixed $value* @return Inputfield|InputfieldSelect**/public function set($key, $value) {if($key == 'options') {if(is_string($value)) {return $this->addOptionsString($value);} else if(is_array($value)) {$this->options = $value;}return $this;} else if(strpos($key, 'options') === 0 && $this->hasFieldtype === false) {list(,$languageID) = explode('options', $key);if(ctype_digit($languageID)) {$this->addOptionLabelsString($value, (int) $languageID);return $this;}} else if($key == 'optionAttributes') {if(is_array($value)) {$this->optionAttributes = $value;}return $this;}return parent::set($key, $value);}/*** Set attribute** @param array|string $key* @param array|int|string $value* @return Inputfield|InputfieldSelect**/public function setAttribute($key, $value) {if($key === 'value') {if(is_object($value) || (is_string($value) && strpos($value, '|'))) {$value = (string) $value;if($this instanceof InputfieldHasArrayValue) {$value = explode('|', $value);}} else if(is_array($value)) {if($this instanceof InputfieldHasArrayValue) {// ok} else {$value = reset($value);}}if($this->valueAddOption) {// add option(s) for any value set from APIif(is_array($value)) {foreach($value as $v) {if(!$this->isOption($v)) {if(strlen($v)) $this->addOption($v);}}} else {if(strlen("$value") && !$this->isOption($value)) {$this->addOption($value);}}}}return parent::setAttribute($key, $value);}/*** Is the value empty?** @return bool**/public function isEmpty() {$value = $this->attr('value');if(is_array($value)) {$cnt = count($value);if(!$cnt) return true;if($cnt === 1) return strlen(reset($value)) === 0;return false; // $cnt > 1} else if($value === null || $value === false) {return true;} else if("$value" === "0") {if(!array_key_exists("$value", $this->options)) return true;} else {return strlen($value) === 0;}return false;}/*** Field configuration** @return InputfieldWrapper**/public function ___getConfigInputfields() {$inputfields = parent::___getConfigInputfields();$modules = $this->wire()->modules;if($this instanceof InputfieldHasArrayValue) {/** @var InputfieldTextarea $f */$f = $modules->get('InputfieldTextarea');$f->description = $this->_('To have pre-selected default value(s), enter the option values (one per line) below.');} else {/** @var InputfieldText $f */$f = $modules->get('InputfieldText');$f->description = $this->_('To have a pre-selected default value, enter the option value below.');}$f->attr('name', 'defaultValue');$f->label = $this->_('Default value');$f->attr('value', $this->defaultValue);$f->description .= ' ' . $this->_('For default page selection, the value would be the page ID number.');$f->notes = $this->_('IMPORTANT: The default value is not used unless the field is required (see the “required” checkbox on this screen).');$f->collapsed = $this->hasFieldtype === false ? Inputfield::collapsedBlank : Inputfield::collapsedNo;$inputfields->add($f);// if dealing with an inputfield that has an associated fieldtype,// we don't need to perform the remaining configurationif($this->hasFieldtype !== false) return $inputfields;// the following configuration specific to non-Fieldtype use of single/multi-selects$isInputfieldSelect = $this->className() == 'InputfieldSelect';$languages = $this->wire()->languages;/** @var InputfieldTextarea $f */$f = $modules->get('InputfieldTextarea');$f->attr('name', 'options');$f->label = $this->_('Options');$value = '';foreach($this->options as $key => $option) {if(is_array($option)) {$value .= "$key\n";foreach($option as $o) {$value .= " $o\n";}} else {$value .= "$option\n";}}$value = trim($value);if(empty($value)) {$optionLabel = $f->label;if($optionLabel === 'Options') $optionLabel = 'Option';$value = "=\n$optionLabel 1\n$optionLabel 2\n$optionLabel 3";if(!$isInputfieldSelect) $value = ltrim($value, '=');}$f->attr('value', $value);$f->attr('rows', 10);$f->description = $this->_('Enter the options that may be selected, one per line.');if($languages) $f->description .= ' ' . $this->_('To use multi-language option labels, please see the instructions below this field.');$f->notes =($languages ? '**' . $this->_('Instructions:') . "**\n" : '') .'• ' . $this->_('Specify one option per line.') . "\n" .'• ' . $this->_('To keep a separate value and label, separate them with an equals sign. Example: value=My Option') . " \n" .($isInputfieldSelect ? '• ' . $this->_('To precede your list with a blank option, enter just a equals sign "=" as the first option.') . "\n" : '') .'• ' . $this->_('To make an option selected, precede it with a plus sign. Example: +My Option') .($isInputfieldSelect ? "\n• " . $this->_('To create an optgroup (option group) indent the options in the group with 3 or more spaces.') : '');if($languages) $f->notes .= " \n\n**" . $this->_('Multi-language instructions:') . "**\n" .'• ' . $this->_('We recommend using using `value=label`, where `value` is the same across languages and `label` is translated.') . " \n" .'• ' . $this->_('First define your default language options, and then copy/paste into the other languages and translate labels.') . " \n" .'• ' . $this->_('Selected options and optgroups are defined on the default language; the other inputs are only for label translation.') . "\n" .'• ' . $this->_('Labels that are not translated inherit the default language label.');if($languages) {$f->useLanguages = true;}$inputfields->add($f);return $inputfields;}}