artabro/wire/modules/Inputfield/InputfieldToggle/InputfieldToggle.module

890 lines
25 KiB
Text
Raw Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
/**
* Toggle Inputfield
*
* An Inputfield for handling an on/off toggle that maintains a boolean value (or null when no selection).
* This provides an alternative to a single checkbox field.
*
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
* https://processwire.com
*
* API usage example
* ~~~~~~
* // Default behavior displays toggle of "Yes" and "No":
* $f = $modules->get('InputfieldToggle');
* $f->attr('name', 'test_toggle_field');
* $f->label = 'Do you like toggle fields?';
*
* // Optionally make it show "On" and "Off" (rather than "Yes" and "No"):
* $f->labelType = InputfieldToggle::labelTypeOn;
*
* // Optionally set custom labels:
* $f->labelType = InputfieldToggle::labelTypeCustom;
* $f->yesLabel = 'Yes please';
* $f->noLabel = 'No thanks';
*
* // Optionally add an "other" option with label "Not sure":
* $f->useOther = true;
* $f->otherLabel = 'Not sure';
*
* // Set the value: 0=No, 1=Yes, 2=Other, or blank string '' for no selection (Unknown)
* $f->val(1);
*
* // Optionally set to use radio buttons rather than toggle buttons:
* $f->inputfieldClass = 'InputfieldRadios';
*
* // Remember to add to your InputfieldForm, InputfieldWrapper or InputfieldFieldset:
* $form->add($f);
* echo $form->render();
* ~~~~~~
*
* @property int|string $value Integer value when selection is made or blank string when no selection (0=No, 1=Yes, 2=Other, ''=Unknown)
* @property int $labelType Label type to use, see the labelType constants (default=labelTypeYes)
* @property int $valueType Type of value for methods that ask for it (use one of the valueType constants)
* @property string $yesLabel Custom yes/on label
* @property string $noLabel Custom no/off label
* @property string $otherLabel Custom label for optional other value Label to use for "other" option
* @property int|bool $useReverse Reverse the order of the Yes/No options? (default=false)
* @property int|bool $useOther Use the "other" option? (default=false)
* @property bool|int $useVertical Use vertically oriented radio buttons? Applies only if $inputfieldClass is 'InputfieldRadios' (default=false)
* @property bool|int $useDeselect Allow radios or toggles to be de-selected, enabling possibility of no-selection? (default=false)
* @property string $defaultOption Default selected value of 'no', 'yes', 'other' or 'none' (default='none')
* @property string $inputfieldClass Inputfield class to use or blank for this toggle buttons (default='')
*
* @method InputfieldSelect|InputfieldRadios getInputfield()
*
*/
class InputfieldToggle extends Inputfield {
public static function getModuleInfo() {
return array(
'title' => __('Toggle', __FILE__),
'summary' => __('A toggle providing similar input capability to a checkbox but much more configurable.', __FILE__),
'version' => 1,
);
}
// label type constants
const labelTypeYes = 0;
const labelTypeTrue = 1;
const labelTypeOn = 2;
const labelTypeEnabled = 3;
const labelTypeCustom = 100;
// value constants
const valueNo = 0;
const valueYes = 1;
const valueOther = 2;
const valueUnknown = '';
/**
* Array of all label types
*
* @var array
*
*/
protected $labelTypes = array(
'yes' => self::labelTypeYes,
'true' => self::labelTypeOn,
'on' => self::labelTypeTrue,
'enabled' => self::labelTypeEnabled,
'custom' => self::labelTypeCustom,
);
/**
* Array of all value types
*
* @var array
*
*/
protected $valueTypes = array(
'no' => self::valueNo,
'yes' => self::valueYes,
'other' => self::valueOther,
'unknown' => self::valueUnknown
);
/**
* Deleted Inputfield object for rendering (InputfieldRadios, InputfieldSelect, etc.)
*
* @var InputfieldSelect|InputfieldRadios Or any that extends them and does not have array value
*
*/
protected $inputfield = null;
/**
* Cached result of a getAllLabels() call
*
* @var array
*
*/
protected $allLabels = array();
/**
* Manually added custom options of [ value => label ]
*
* @var array
*
*/
protected $customOptions = array();
/**
* Construct and set default settings
*
*/
public function __construct() {
$this->set('labelType', self::labelTypeYes);
$this->set('yesLabel', '✓');
$this->set('noLabel', '✗');
$this->set('otherLabel', $this->_('?'));
$this->set('useOther', 0);
$this->set('useReverse', 0);
$this->set('useVertical', 0);
$this->set('useDeselect', 0);
$this->set('defaultOption', 'none');
$this->set('inputfieldClass', '0');
$this->set('settings', array(
'inputCheckedClass' => '',
'labelCheckedClass' => '',
));
$this->attr('value', self::valueUnknown);
parent::__construct();
}
public function wired() {
$languages = $this->wire('languages');
if($languages) {
foreach($languages as $language) {
if($language->isDefault()) continue;
$this->set("yesLabel$language", '');
$this->set("noLabel$language", '');
$this->set("otherLabel$language", '');
}
}
parent::wired();
}
/**
* Is the current value empty? (i.e. no selection)
*
* @return bool
*
*/
public function isEmpty() {
$value = $this->val();
if($value === self::valueUnknown) return true;
if(is_int($value)) {
if($this->hasCustomOptions()) {
if(isset($this->customOptions[$value])) return false;
} else {
if($value > -1) return false;
}
} else if($value && $value !== 'unknown' && isset($this->valueTypes[$value])) {
return false;
}
if($value === self::valueOther && $this->useOther) return false;
return true;
}
/**
* Sanitize the value to be one ofthe constants: valueYes, valueNo, valueOther, valueUnknown
*
* @param string|int $value Value to sanitize
* @param bool $getName Get internal name of value rather than value? (default=false)
* @return int|string
*
*/
public function sanitizeValue($value, $getName = false) {
if($value === null) {
return $getName ? 'unknown' : self::valueUnknown;
}
if(is_bool($value)) {
if($getName) return $value ? 'yes' : 'no';
return $value ? self::valueYes : self::valueNo;
}
$intValue = strlen("$value") && ctype_digit("$value") ? (int) "$value" : '';
$strValue = strtolower("$value");
if($this->hasCustomOptions()) {
if($intValue !== '') $value = $intValue;
$value = isset($this->customOptions[$value]) ? $value : self::valueUnknown;
} else if($intValue === self::valueNo || $intValue === self::valueYes) {
$value = $intValue;
} else if($intValue === self::valueOther) {
$value = $intValue;
} else if($strValue === 'yes' || $strValue === 'on' || $strValue === 'true') {
$value = self::valueYes;
} else if($strValue === 'no' || $strValue === 'off' || $strValue === 'false') {
$value = self::valueNo;
} else if($strValue === 'unknown' || $strValue === '') {
$value = self::valueUnknown;
} else if(is_string($value) && strlen($value)) {
// attempt to match to a label
$value = null;
foreach($this->getAllLabels() as $key => $label) {
if(strtolower($label) !== $strValue) continue;
list($labelType, $valueType, $languageName) = explode(':', $key);
if($labelType || $languageName) {} // ignore
$value = $this->valueTypes[$valueType];
break;
}
if($value === null) $value = self::valueUnknown;
} else {
$value = self::valueUnknown; // blank string
}
if($getName && !$this->hasCustomOptions()) {
if($value === self::valueUnknown) {
$value = 'unknown';
} else if($value === self::valueYes) {
$value = 'yes';
} else if($value === self::valueNo) {
$value = 'no';
} else if($value === self::valueOther) {
$value = 'other';
}
}
return $value;
}
/**
* Set attribute
*
* @param array|string $key
* @param array|bool|int|string $value
* @return Inputfield
*
*/
public function setAttribute($key, $value) {
if($key === 'value') $value = $this->sanitizeValue($value);
return parent::setAttribute($key, $value);
}
/**
* Get the delegated Inputfield that will be used for rendering selectable options
*
* @return InputfieldRadios|InputfieldSelect|InputfieldToggle
*
*/
public function ___getInputfield() {
if($this->inputfield) return $this->inputfield;
$class = $this->getSetting('inputfieldClass');
if(empty($class) || $class === $this->className()) {
if(false && $this->wire('adminTheme') == 'AdminThemeDefault') {
// clicking toggles jumps to top of page on AdminThemeDefault for some reason
// even if JS click events are canceled, so use radios instead
$class = 'InputfieldRadios';
} else {
return $this;
}
}
$f = $this->wire('modules')->get($class);
if(!$f || $f === $this) return $this;
$this->addClass($class, 'wrapClass');
/** @var InputfieldSelect|InputfieldRadios $f */
$f->attr('name', $this->attr('name'));
$f->attr('id', $this->attr('id'));
$f->addClass($this->attr('class'));
if(!$this->useVertical) {
$f->set('optionColumns', 1);
}
$val = $this->val();
$options = $this->getOptions();
$f->addOptions($options);
if(isset($options[$val]) && method_exists($f, 'addOptionAttributes')) {
$f->addOptionAttributes($val, array('input.class' => 'InputfieldToggleChecked'));
}
$f->val($val);
$this->inputfield = $f;
return $f;
}
/**
* Render ready
*
* @param Inputfield|null $parent
* @param bool $renderValueMode
* @return bool
*
*/
public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
$f = $this->getInputfield();
if($f && $f !== $this) $f->renderReady($parent, $renderValueMode);
if($this->useDeselect && $this->defaultOption === 'none') {
$this->addClass('InputfieldToggleUseDeselect', 'wrapClass');
}
return parent::renderReady($parent, $renderValueMode);
}
/**
* Render value
*
* @return string
*
*/
public function ___renderValue() {
$label = $this->getValueLabel($this->attr('value'));
$value = $this->formatLabel($label, true);
return $value;
}
/**
* Render input element(s)
*
* @return string
*
*/
public function ___render() {
$value = $this->val();
$default = $this->getSetting('defaultOption');
// check if we should assign a default value
if($default && ("$value" === self::valueUnknown || !strlen("$value"))) {
if($default === 'yes') {
$this->val(self::valueYes);
} else if($default === 'no') {
$this->val(self::valueNo);
} else if($default === 'other' && $this->useOther) {
$this->val(self::valueOther);
}
}
$f = $this->getInputfield();
if($f && $f !== $this) {
$f->val($this->val());
$out = $f->render();
} else {
$out = $this->renderToggle();
}
// hidden input to indicate presence when no selection is made (like with radios)
/** @var InputfieldButton $btn */
$button = $this->wire('modules')->get('InputfieldButton');
$button->setSecondary(true);
$button->val('1');
$button->removeAttr('name');
$input = $this->wire('modules')->get('InputfieldText');
$input->attr('name', "_{$this->name}_");
$input->val(1);
$out .= "<div class='InputfieldToggleHelper'>" . $input->render() . $button->render() . "</div>";
return $out;
}
/**
* Render default input toggles
*
* @return string
*
*/
protected function renderToggle() {
$id = $this->attr('id');
$name = $this->attr('name');
$checkedValue = $this->val();
$out = '';
foreach($this->getOptions() as $value => $label) {
$checked = "$checkedValue" === "$value" ? "checked " : "";
$inputClass = $checked ? 'InputfieldToggleChecked' : '';
$labelClass = $checked ? 'InputfieldToggleCurrent' : '';
$label = $this->formatLabel($label);
$out .=
"<input type='radio' id='{$id}_$value' name='$name' class='$inputClass' value='$value' $checked/>" .
"<label for='{$id}_$value' class='$labelClass'><span class='pw-no-select'>$label</span></label>";
}
return
"<div class='pw-clearfix ui-helper-clearfix'>" .
"<div class='InputfieldToggleGroup'>$out</div>" .
"</div>";
}
/**
* Process input
*
* @param WireInputData $input
* @return $this
*
*/
public function ___processInput(WireInputData $input) {
$prevValue = $this->val();
$value = $input[$this->name];
$intValue = strlen("$value") && ctype_digit("$value") ? (int) $value : null;
if($value === null && $input["_{$this->name}_"] === null) {
// input was not rendered in the submitted post, so should be ignored
} else if($this->hasCustomOptions()) {
// custom options
if(isset($this->customOptions[$value])) $this->val($value);
} else if($intValue === self::valueYes || $intValue === self::valueNo) {
// yes or no selected
$this->val($intValue);
} else if($intValue === self::valueOther && $this->useOther) {
// other selected
$this->val($intValue);
} else if($value === self::valueUnknown || $value === null) {
// no selection
$this->val(self::valueUnknown);
} else {
// something we don't recognize
}
if($this->val() !== $prevValue) {
$this->trackChange('value', $prevValue, $this->val());
}
return $this;
}
/**
* Get labels for the given label type
*
* @param int $labelType Specify toggle type constant or omit to use configured toggle type.
* @param Language|int|string|null Language or omit to use current users language. (default=null)
* @return array Returned array has these indexes:
* `no` (string): No/Off state label
* `yes` (string): Yes/On state label
* `other` (string): Other state label
* `unknown` (string): No selection label
*
*/
public function getLabels($labelType = null, $language = null) {
if($labelType === null) $labelType = $this->labelType;
/** @var Languages $langauges */
$languages = $this->wire('languages');
$setLanguage = false;
$languageId = '';
$yes = '';
$no = '';
if($languages) {
/** @var User $user */
$user = $this->wire('user');
if(empty($language)) {
// use current user language
$language = $user->language;
} else if(is_int($language) || is_string($language)) {
// get language from specified language ID or name
$language = $languages->get($language);
}
if($language instanceof Page && $language->id != $user->language->id) {
// use other specified language
$languages->setLanguage($language);
$setLanguage = true;
} else {
// use current user language
$language = $user->language;
}
$languageId = $language && !$language->isDefault() ? $language->id : '';
}
switch($labelType) {
case self::labelTypeTrue:
$yes = $this->_('True');
$no = $this->_('False');
break;
case self::labelTypeOn:
$yes = $this->_('On');
$no = $this->_('Off');
break;
case self::labelTypeEnabled:
$yes = $this->_('Enabled');
$no = $this->_('Disabled');
break;
case self::labelTypeCustom:
$yes = $languageId ? $this->get("yesLabel$languageId|yesLabel") : $this->yesLabel;
$no = $languageId ? $this->get("noLabel$languageId|noLabel") : $this->noLabel;
break;
}
// default (labelTypeYes)
if(!strlen($yes)) $yes = $this->_('Yes');
if(!strlen($no)) $no = $this->_('No');
// other and unknown labels
$other = $languageId ? $this->get("otherLabel$languageId|otherLabel") : $this->otherLabel;
if(empty($other)) $other = $this->_('Other');
$unknown = $this->_('Unknown');
if($setLanguage && $languages) $languages->unsetLanguage();
return array(
'no' => $no,
'yes' => $yes,
'other' => $other,
'unknown' => $unknown
);
}
/**
* Get all possible labels for all label types and all languages
*
* Returned array of labels (strings) indexed by "labelTypeNum:valueTypeName:languageName"
*
* @return array
*
*/
public function getAllLabels() {
if(!empty($this->allLabels)) return $this->allLabels;
/** @var Languages|null $languages */
$languages = $this->wire('languages');
$all = array();
foreach($this->labelTypes as $labelType) {
if($languages) {
foreach($languages as $language) {
foreach($this->getLabels($labelType, $language) as $valueType => $label) {
$all["$labelType:$valueType:$language->name"] = $label;
}
}
} else {
foreach($this->getLabels($labelType) as $valueType => $label) {
$all["$labelType:$valueType:default"] = $label;
}
}
}
return $all;
}
/**
* Get the label for the currently set (or given) value
*
* @param bool|int|string|null $value Optionally provide value or omit to use currently set value attribute.
* @param int|null $labelType Specify labelType constant or omit for selected label type.
* @param Language|int|string $language
* @return string Label string
*
*/
public function getValueLabel($value = null, $labelType = null, $language = null) {
if($value === null) $value = $this->val();
if($this->hasCustomOptions()) {
// get custom defined option label from addOption() call (API usage only)
return isset($this->customOptions[$value]) ? $this->customOptions[$value] : self::valueUnknown;
}
$labels = $this->getLabels($labelType, $language);
if($value === null || $value === self::valueUnknown) return $labels['unknown'];
if(is_bool($value)) return $value ? $labels['yes'] : $labels['no'];
if($value === self::valueOther) return $labels['other'];
if($value === self::valueYes) return $labels['yes'];
if($value === self::valueNo) return $labels['no'];
return $labels['unknown'];
}
/**
* Format label for HTML output (entity encode, etc.)
*
* @param string $label
* @param bool $allowIcon Allow icon markup to appear in label?
* @return string
*
*/
public function formatLabel($label, $allowIcon = true) {
$label = $this->wire('sanitizer')->entities1($label);
if(strpos($label, 'icon-') !== false && preg_match('/\bicon-([-_a-z0-9]+)/', $label, $matches)) {
$name = $matches[1];
$icon = $allowIcon ? $icon = wireIconMarkup($name, 'fw') : '';
$label = str_replace("icon-$name", $icon, $label);
}
return trim($label);
}
/**
* Get all selectable options as array of [ value => label ]
*
* @return array
*
*/
public function getOptions() {
// use custom options instead if any have been set from an addOption() call
if($this->hasCustomOptions()) return $this->customOptions;
// use built in toggle options
$options = array();
$values = $this->useReverse ? array(self::valueNo, self::valueYes) : array(self::valueYes, self::valueNo);
if($this->useOther) $values[] = self::valueOther;
foreach($values as $value) {
$options[$value] = $this->getValueLabel($value);
}
return $options;
}
/**
* Add a selectable option (custom API usage only, overrides built-in options)
*
* Note that once you use this, your options take over and Toggle's default yes/no/other
* are no longer applicable. This is for custom API use and is not used by FieldtypeToggle.
*
* @param int|string $value
* @param null|string $label
* @return $this
* @throws WireException if you attempt to call this method when used with FieldtypeToggle
*
*/
public function addOption($value, $label = null) {
if($this->hasFieldtype) {
throw new WireException('The addOption() method is not available for FieldtypeToggle');
}
if($label === null) $label = $value;
$this->customOptions[$value] = $label;
return $this;
}
/**
* Set all options with array of [ value => label ] (custom API usage only, overrides built-in options)
*
* Once you use this (with a non-empty array, your set options take over and the
* built-in yes/no/other no longer apply. This is for custom API use and is not used
* by FieldtypeToggle.
*
* The value for each option must be an integer value between -128 and 127.
*
* @param array $options
* @return $this
* @throws WireException if you attempt to call this method when used with FieldtypeToggle
*
*/
public function setOptions(array $options) {
$this->customOptions = array();
foreach($options as $key => $value) {
$this->addOption($key, $value);
}
return $this;
}
/**
* Are custom options in use?
*
* @return bool
*
*/
protected function hasCustomOptions() {
return count($this->customOptions) > 0;
}
/**
* Return a list of config property names allowed for fieldgroup/template context
*
* @param Field $field
* @return array of Inputfield names
* @see Fieldtype::getConfigAllowContext()
*
*/
public function ___getConfigAllowContext($field) {
return array_merge(parent::___getConfigAllowContext($field), array(
'labelType',
'inputfieldClass',
'yesLabel',
'noLabel',
'otherLabel',
'useVertical',
'useDeselect',
'useOther',
'useReverse',
'defaultOption',
));
}
/**
* Configure Inputfield
*
* @return InputfieldWrapper
*
*/
public function ___getConfigInputfields() {
/** @var Modules $modules */
$modules = $this->wire('modules');
/** @var Languages $languages */
$languages = $this->wire('languages');
$inputfields = parent::___getConfigInputfields();
if($this->hasFieldtype) {
/** @var InputfieldFieldset $fieldset */
$fieldset = $modules->get('InputfieldFieldset');
$fieldset->label = $this->_('Toggle field labels and input settings');
$fieldset->icon = 'toggle-on';
$inputfields->prepend($fieldset);
} else {
$fieldset = $inputfields;
}
$removals = array('defaultValue');
foreach($removals as $name) {
$f = $inputfields->getChildByName($name);
if($f) $inputfields->remove($f);
}
/** @var InputfieldRadios $f */
$f = $modules->get('InputfieldRadios');
$f->attr('name', 'labelType');
$f->label = $this->_('Label type');
foreach($this->labelTypes as $labelType) {
if($labelType == self::labelTypeCustom) {
$label = $this->_('Custom');
} else {
$label = $this->getValueLabel(self::valueYes, $labelType) . '/' . $this->getValueLabel(self::valueNo, $labelType);
}
$f->addOption($labelType, $label);
}
$f->attr('value', (int) $this->labelType);
$f->columnWidth = 34;
$fieldset->add($f);
/** @var InputfieldRadios $f */
$f = $modules->get('InputfieldRadios');
$f->attr('name', 'inputfieldClass');
$f->label = $this->_('Input type');
$f->addOption('0', $this->_('Toggle buttons'));
foreach($modules->findByPrefix('Inputfield') as $name) {
if(!wireInstanceOf($name, 'InputfieldSelect')) continue;
if(wireInstanceOf($name, 'InputfieldHasArrayValue')) continue;
$label = str_replace('Inputfield', '', $name);
$f->addOption($name, $label);
}
$f->val($this->getSetting('inputfieldClass'));
$f->columnWidth = 33;
$fieldset->add($f);
/** @var InputfieldRadios $f */
$f = $modules->get('InputfieldRadios');
$f->attr('name', 'useVertical');
$f->label = $this->_('Radios');
$f->addOption(0, $this->_('Horizontal'));
$f->addOption(1, $this->_('Vertical'));
$f->val($this->useVertical ? 1 : 0);
$f->columnWidth = 33;
$f->showIf = 'inputfieldClass=InputfieldRadios';
$fieldset->add($f);
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'useOther');
$f->label = $this->_('Use a 3rd “other” option?');
if($this->useOther) $f->attr('checked', 'checked');
$f->columnWidth = 34;
$fieldset->add($f);
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'useReverse');
$f->label = $this->_('Reverse order of yes/no options?');
if($this->useReverse) $f->attr('checked', 'checked');
$f->columnWidth = 33;
$fieldset->add($f);
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'useDeselect');
$f->label = $this->_('Support click to de-select?');
$f->showIf = "inputfieldClass=0|InputfieldRadios";
if($this->useDeselect) $f->attr('checked', 'checked');
$f->columnWidth = 33;
$f->showIf = 'defaultOption=none';
$fieldset->add($f);
$customStates = array(
'yesLabel' => $this->_('yes/on'),
'noLabel' => $this->_('no/off'),
);
$labelFor = $this->_('Label for “%s” option');
/** @var InputfieldText $f */
foreach($customStates as $name => $label) {
$f = $modules->get('InputfieldText');
$f->attr('name', $name);
$f->label = sprintf($labelFor, $label);
$f->showIf = 'labelType=' . self::labelTypeCustom;
$f->val($this->get($name));
$f->columnWidth = 50;
if($languages) {
$f->useLanguages = true;
foreach($languages as $language) {
$langValue = $this->get("$name$language");
if(!$language->isDefault()) $f->set("value$language", $langValue);
}
}
$fieldset->add($f);
}
/** @var InputfieldText $f */
$f = $modules->get('InputfieldText');
$f->attr('name', 'otherLabel');
$f->label = sprintf($labelFor, $this->_('other'));
$f->showIf = 'useOther=1';
$f->val($this->get('otherLabel'));
if($languages) {
$f->useLanguages = true;
foreach($languages as $language) {
if(!$language->isDefault()) $f->set("value$language", $this->get("otherLabel$language"));
}
}
$fieldset->add($f);
/** @var InputfieldToggle $f */
$f = $modules->get('InputfieldToggle');
$f->set('inputfieldClass', $this->inputfieldClass);
$f->attr('name', 'defaultOption');
$f->label = $this->_('Default selected option');
$f->addOption('yes', $this->getValueLabel(self::valueYes));
$f->addOption('no', $this->getValueLabel(self::valueNo));
if($this->useOther) $f->addOption('other', $this->getValueLabel(self::valueOther));
$f->addOption('none', $this->_('No selection'));
$f->val($this->defaultOption);
$f->addClass('InputfieldToggle', 'wrapClass');
$fieldset->add($f);
return $inputfields;
}
}