84df3f77351de0d6bef30d7063bbef089e0d9408
[yaffs-website] / src / Utility / Element.php
1 <?php
2 /**
3  * @file
4  * Contains \Drupal\bootstrap\Utility\Element.
5  */
6
7 namespace Drupal\bootstrap\Utility;
8
9 use Drupal\bootstrap\Bootstrap;
10 use Drupal\Component\Render\FormattableMarkup;
11 use Drupal\Component\Render\MarkupInterface;
12 use Drupal\Component\Utility\Xss;
13 use Drupal\Core\Form\FormStateInterface;
14
15 /**
16  * Provides helper methods for Drupal render elements.
17  *
18  * @ingroup utility
19  *
20  * @see \Drupal\Core\Render\Element
21  */
22 class Element extends DrupalAttributes {
23
24   /**
25    * The current state of the form.
26    *
27    * @var \Drupal\Core\Form\FormStateInterface
28    */
29   protected $formState;
30
31   /**
32    * The element type.
33    *
34    * @var string
35    */
36   protected $type = FALSE;
37
38   /**
39    * {@inheritdoc}
40    */
41   protected $attributePrefix = '#';
42
43   /**
44    * Element constructor.
45    *
46    * @param array|string $element
47    *   A render array element.
48    * @param \Drupal\Core\Form\FormStateInterface $form_state
49    *   The current state of the form.
50    */
51   public function __construct(&$element = [], FormStateInterface $form_state = NULL) {
52     if (!is_array($element)) {
53       $element = ['#markup' => $element instanceof MarkupInterface ? $element : new FormattableMarkup($element, [])];
54     }
55     $this->array = &$element;
56     $this->formState = $form_state;
57   }
58
59   /**
60    * Magic get method.
61    *
62    * This is only for child elements, not properties.
63    *
64    * @param string $key
65    *   The name of the child element to retrieve.
66    *
67    * @return \Drupal\bootstrap\Utility\Element
68    *   The child element object.
69    *
70    * @throws \InvalidArgumentException
71    *   Throws this error when the name is a property (key starting with #).
72    */
73   public function &__get($key) {
74     if (\Drupal\Core\Render\Element::property($key)) {
75       throw new \InvalidArgumentException('Cannot dynamically retrieve element property. Please use \Drupal\bootstrap\Utility\Element::getProperty instead.');
76     }
77     $instance = new self($this->offsetGet($key, []));
78     return $instance;
79   }
80
81   /**
82    * Magic set method.
83    *
84    * This is only for child elements, not properties.
85    *
86    * @param string $key
87    *   The name of the child element to set.
88    * @param mixed $value
89    *   The value of $name to set.
90    *
91    * @throws \InvalidArgumentException
92    *   Throws this error when the name is a property (key starting with #).
93    */
94   public function __set($key, $value) {
95     if (\Drupal\Core\Render\Element::property($key)) {
96       throw new \InvalidArgumentException('Cannot dynamically retrieve element property. Use \Drupal\bootstrap\Utility\Element::setProperty instead.');
97     }
98     $this->offsetSet($key, ($value instanceof Element ? $value->getArray() : $value));
99   }
100
101   /**
102    * Magic isset method.
103    *
104    * This is only for child elements, not properties.
105    *
106    * @param string $name
107    *   The name of the child element to check.
108    *
109    * @return bool
110    *   TRUE or FALSE
111    *
112    * @throws \InvalidArgumentException
113    *   Throws this error when the name is a property (key starting with #).
114    */
115   public function __isset($name) {
116     if (\Drupal\Core\Render\Element::property($name)) {
117       throw new \InvalidArgumentException('Cannot dynamically check if an element has a property. Use \Drupal\bootstrap\Utility\Element::unsetProperty instead.');
118     }
119     return parent::__isset($name);
120   }
121
122   /**
123    * Magic unset method.
124    *
125    * This is only for child elements, not properties.
126    *
127    * @param mixed $name
128    *   The name of the child element to unset.
129    *
130    * @throws \InvalidArgumentException
131    *   Throws this error when the name is a property (key starting with #).
132    */
133   public function __unset($name) {
134     if (\Drupal\Core\Render\Element::property($name)) {
135       throw new \InvalidArgumentException('Cannot dynamically unset an element property. Use \Drupal\bootstrap\Utility\Element::hasProperty instead.');
136     }
137     parent::__unset($name);
138   }
139
140   /**
141    * Appends a property with a value.
142    *
143    * @param string $name
144    *   The name of the property to set.
145    * @param mixed $value
146    *   The value of the property to set.
147    *
148    * @return $this
149    */
150   public function appendProperty($name, $value) {
151     $property = &$this->getProperty($name);
152     $value = $value instanceof Element ? $value->getArray() : $value;
153
154     // If property isn't set, just set it.
155     if (!isset($property)) {
156       $property = $value;
157       return $this;
158     }
159
160     if (is_array($property)) {
161       $property[] = Element::create($value)->getArray();
162     }
163     else {
164       $property .= (string) $value;
165     }
166
167     return $this;
168   }
169
170   /**
171    * Identifies the children of an element array, optionally sorted by weight.
172    *
173    * The children of a element array are those key/value pairs whose key does
174    * not start with a '#'. See drupal_render() for details.
175    *
176    * @param bool $sort
177    *   Boolean to indicate whether the children should be sorted by weight.
178    *
179    * @return array
180    *   The array keys of the element's children.
181    */
182   public function childKeys($sort = FALSE) {
183     return \Drupal\Core\Render\Element::children($this->array, $sort);
184   }
185
186   /**
187    * Retrieves the children of an element array, optionally sorted by weight.
188    *
189    * The children of a element array are those key/value pairs whose key does
190    * not start with a '#'. See drupal_render() for details.
191    *
192    * @param bool $sort
193    *   Boolean to indicate whether the children should be sorted by weight.
194    *
195    * @return \Drupal\bootstrap\Utility\Element[]
196    *   An array child elements.
197    */
198   public function children($sort = FALSE) {
199     $children = [];
200     foreach ($this->childKeys($sort) as $child) {
201       $children[$child] = new self($this->array[$child]);
202     }
203     return $children;
204   }
205
206   /**
207    * Adds a specific Bootstrap class to color a button based on its text value.
208    *
209    * @param bool $override
210    *   Flag determining whether or not to override any existing set class.
211    *
212    * @return $this
213    */
214   public function colorize($override = TRUE) {
215     $button = $this->isButton();
216
217     // @todo refactor this more so it's not just "button" specific.
218     $prefix = $button ? 'btn' : 'has';
219
220     // List of classes, based on the prefix.
221     $classes = [
222       "$prefix-primary", "$prefix-success", "$prefix-info",
223       "$prefix-warning", "$prefix-danger", "$prefix-link",
224       // Default should be last.
225       "$prefix-default"
226     ];
227
228     // Set the class to "btn-default" if it shouldn't be colorized.
229     $class = $button && !Bootstrap::getTheme()->getSetting('button_colorize') ? 'btn-default' : FALSE;
230
231     // Search for an existing class.
232     if (!$class || !$override) {
233       foreach ($classes as $value) {
234         if ($this->hasClass($value)) {
235           $class = $value;
236           break;
237         }
238       }
239     }
240
241     // Find a class based on the value of "value", "title" or "button_type".
242     if (!$class) {
243       $value = $this->getProperty('value', $this->getProperty('title', ''));
244       $class = "$prefix-" . Bootstrap::cssClassFromString($value, $button ? $this->getProperty('button_type', 'default') : 'default');
245     }
246
247     // Remove any existing classes and add the specified class.
248     if ($class) {
249       $this->removeClass($classes)->addClass($class);
250       if ($button && $this->getProperty('split')) {
251         $this->removeClass($classes, $this::SPLIT_BUTTON)->addClass($class, $this::SPLIT_BUTTON);
252       }
253     }
254
255     return $this;
256   }
257
258   /**
259    * Creates a new \Drupal\bootstrap\Utility\Element instance.
260    *
261    * @param array|string $element
262    *   A render array element or a string.
263    * @param \Drupal\Core\Form\FormStateInterface $form_state
264    *   A current FormState instance, if any.
265    *
266    * @return \Drupal\bootstrap\Utility\Element
267    *   The newly created element instance.
268    */
269   public static function create(&$element = [], FormStateInterface $form_state = NULL) {
270     return $element instanceof self ? $element : new self($element, $form_state);
271   }
272
273   /**
274    * Creates a new standalone \Drupal\bootstrap\Utility\Element instance.
275    *
276    * It does not reference the original element passed. If an Element instance
277    * is passed, it will clone it so it doesn't affect the original element.
278    *
279    * @param array|string|\Drupal\bootstrap\Utility\Element $element
280    *   A render array element, string or Element instance.
281    * @param \Drupal\Core\Form\FormStateInterface $form_state
282    *   A current FormState instance, if any.
283    *
284    * @return \Drupal\bootstrap\Utility\Element
285    *   The newly created element instance.
286    */
287   public static function createStandalone($element = [], FormStateInterface $form_state = NULL) {
288     // Immediately return a cloned version if element is already an Element.
289     if ($element instanceof self) {
290       return clone $element;
291     }
292     $standalone = is_object($element) ? clone $element : $element;
293     return static::create($standalone, $form_state);
294   }
295
296   /**
297    * {@inheritdoc}
298    */
299   public function exchangeArray($data) {
300     $old = parent::exchangeArray($data);
301     return $old;
302   }
303
304   /**
305    * Retrieves the render array for the element.
306    *
307    * @return array
308    *   The element render array, passed by reference.
309    */
310   public function &getArray() {
311     return $this->array;
312   }
313
314   /**
315    * Retrieves a context value from the #context element property, if any.
316    *
317    * @param string $name
318    *   The name of the context key to retrieve.
319    * @param mixed $default
320    *   Optional. The default value to use if the context $name isn't set.
321    *
322    * @return mixed|NULL
323    *   The context value or the $default value if not set.
324    */
325   public function &getContext($name, $default = NULL) {
326     $context = &$this->getProperty('context', []);
327     if (!isset($context[$name])) {
328       $context[$name] = $default;
329     }
330     return $context[$name];
331   }
332
333   /**
334    * Returns the error message filed against the given form element.
335    *
336    * Form errors higher up in the form structure override deeper errors as well
337    * as errors on the element itself.
338    *
339    * @return string|null
340    *   Either the error message for this element or NULL if there are no errors.
341    *
342    * @throws \BadMethodCallException
343    *   When the element instance was not constructed with a valid form state
344    *   object.
345    */
346   public function getError() {
347     if (!$this->formState) {
348       throw new \BadMethodCallException('The element instance must be constructed with a valid form state object to use this method.');
349     }
350     return $this->formState->getError($this->array);
351   }
352
353   /**
354    * Retrieves the render array for the element.
355    *
356    * @param string $name
357    *   The name of the element property to retrieve, not including the # prefix.
358    * @param mixed $default
359    *   The default to set if property does not exist.
360    *
361    * @return mixed
362    *   The property value, NULL if not set.
363    */
364   public function &getProperty($name, $default = NULL) {
365     return $this->offsetGet("#$name", $default);
366   }
367
368   /**
369    * Returns the visible children of an element.
370    *
371    * @return array
372    *   The array keys of the element's visible children.
373    */
374   public function getVisibleChildren() {
375     return \Drupal\Core\Render\Element::getVisibleChildren($this->array);
376   }
377
378   /**
379    * Indicates whether the element has an error set.
380    *
381    * @throws \BadMethodCallException
382    *   When the element instance was not constructed with a valid form state
383    *   object.
384    */
385   public function hasError() {
386     $error = $this->getError();
387     return isset($error);
388   }
389
390   /**
391    * Indicates whether the element has a specific property.
392    *
393    * @param string $name
394    *   The property to check.
395    */
396   public function hasProperty($name) {
397     return $this->offsetExists("#$name");
398   }
399
400   /**
401    * Indicates whether the element is a button.
402    *
403    * @return bool
404    *   TRUE or FALSE.
405    */
406   public function isButton() {
407     return !empty($this->array['#is_button']) || $this->isType(['button', 'submit', 'reset', 'image_button']) || $this->hasClass('btn');
408   }
409
410   /**
411    * Indicates whether the given element is empty.
412    *
413    * An element that only has #cache set is considered empty, because it will
414    * render to the empty string.
415    *
416    * @return bool
417    *   Whether the given element is empty.
418    */
419   public function isEmpty() {
420     return \Drupal\Core\Render\Element::isEmpty($this->array);
421   }
422
423   /**
424    * Indicates whether a property on the element is empty.
425    *
426    * @param string $name
427    *   The property to check.
428    *
429    * @return bool
430    *   Whether the given property on the element is empty.
431    */
432   public function isPropertyEmpty($name) {
433     return $this->hasProperty($name) && empty($this->getProperty($name));
434   }
435
436   /**
437    * Checks if a value is a render array.
438    *
439    * @param mixed $value
440    *   The value to check.
441    *
442    * @return bool
443    *   TRUE if the given value is a render array, otherwise FALSE.
444    */
445   public static function isRenderArray($value) {
446     return is_array($value) && (isset($value['#type']) ||
447       isset($value['#theme']) || isset($value['#theme_wrappers']) ||
448       isset($value['#markup']) || isset($value['#attached']) ||
449       isset($value['#cache']) || isset($value['#lazy_builder']) ||
450       isset($value['#create_placeholder']) || isset($value['#pre_render']) ||
451       isset($value['#post_render']) || isset($value['#process']));
452   }
453
454   /**
455    * Checks if the element is a specific type of element.
456    *
457    * @param string|array $type
458    *   The element type(s) to check.
459    *
460    * @return bool
461    *   TRUE if element is or one of $type.
462    */
463   public function isType($type) {
464     $property = $this->getProperty('type');
465     return $property && in_array($property, (is_array($type) ? $type : [$type]));
466   }
467
468   /**
469    * Determines if an element is visible.
470    *
471    * @return bool
472    *   TRUE if the element is visible, otherwise FALSE.
473    */
474   public function isVisible() {
475     return \Drupal\Core\Render\Element::isVisibleElement($this->array);
476   }
477
478   /**
479    * Maps an element's properties to its attributes array.
480    *
481    * @param array $map
482    *   An associative array whose keys are element property names and whose
483    *   values are the HTML attribute names to set on the corresponding
484    *   property; e.g., array('#propertyname' => 'attributename'). If both names
485    *   are identical except for the leading '#', then an attribute name value is
486    *   sufficient and no property name needs to be specified.
487    *
488    * @return $this
489    */
490   public function map(array $map) {
491     \Drupal\Core\Render\Element::setAttributes($this->array, $map);
492     return $this;
493   }
494
495   /**
496    * Prepends a property with a value.
497    *
498    * @param string $name
499    *   The name of the property to set.
500    * @param mixed $value
501    *   The value of the property to set.
502    *
503    * @return $this
504    */
505   public function prependProperty($name, $value) {
506     $property = &$this->getProperty($name);
507     $value = $value instanceof Element ? $value->getArray() : $value;
508
509     // If property isn't set, just set it.
510     if (!isset($property)) {
511       $property = $value;
512       return $this;
513     }
514
515     if (is_array($property)) {
516       array_unshift($property, Element::create($value)->getArray());
517     }
518     else {
519       $property = (string) $value . (string) $property;
520     }
521
522     return $this;
523   }
524
525   /**
526    * Gets properties of a structured array element (keys beginning with '#').
527    *
528    * @return array
529    *   An array of property keys for the element.
530    */
531   public function properties() {
532     return \Drupal\Core\Render\Element::properties($this->array);
533   }
534
535   /**
536    * Renders the final element HTML.
537    *
538    * @return \Drupal\Component\Render\MarkupInterface
539    *   The rendered HTML.
540    */
541   public function render() {
542     /** @var \Drupal\Core\Render\Renderer $renderer */
543     $renderer = \Drupal::service('renderer');
544     return $renderer->render($this->array);
545   }
546
547   /**
548    * Renders the final element HTML.
549    *
550    * @return \Drupal\Component\Render\MarkupInterface
551    *   The rendered HTML.
552    */
553   public function renderPlain() {
554     /** @var \Drupal\Core\Render\Renderer $renderer */
555     $renderer = \Drupal::service('renderer');
556     return $renderer->renderPlain($this->array);
557   }
558
559   /**
560    * Renders the final element HTML.
561    *
562    * (Cannot be executed within another render context.)
563    *
564    * @return \Drupal\Component\Render\MarkupInterface
565    *   The rendered HTML.
566    */
567   public function renderRoot() {
568     /** @var \Drupal\Core\Render\Renderer $renderer */
569     $renderer = \Drupal::service('renderer');
570     return $renderer->renderRoot($this->array);
571   }
572
573   /**
574    * Adds Bootstrap button size class to the element.
575    *
576    * @param string $class
577    *   The full button size class to add. If none is provided, it will default
578    *   to any set theme setting.
579    * @param bool $override
580    *   Flag indicating if the passed $class should be forcibly set. Setting
581    *   this to FALSE allows any existing set class to persist.
582    *
583    * @return $this
584    */
585   public function setButtonSize($class = NULL, $override = TRUE) {
586     // Immediately return if element is not a button.
587     if (!$this->isButton()) {
588       return $this;
589     }
590
591     // Retrieve the button size classes from the specific setting's options.
592     static $classes;
593     if (!isset($classes)) {
594       $classes = [];
595       if ($button_size = Bootstrap::getTheme()->getSettingPlugin('button_size')) {
596         $classes = array_keys($button_size->getOptions());
597       }
598     }
599
600     // Search for an existing class.
601     if (!$class || !$override) {
602       foreach ($classes as $value) {
603         if ($this->hasClass($value)) {
604           $class = $value;
605           break;
606         }
607       }
608     }
609
610     // Attempt to get the default button size, if set.
611     if (!$class) {
612       $class = Bootstrap::getTheme()->getSetting('button_size');
613     }
614
615     // Remove any existing classes and add the specified class.
616     if ($class) {
617       $this->removeClass($classes)->addClass($class);
618       if ($this->getProperty('split')) {
619         $this->removeClass($classes, $this::SPLIT_BUTTON)->addClass($class, $this::SPLIT_BUTTON);
620       }
621     }
622
623     return $this;
624   }
625
626   /**
627    * Flags an element as having an error.
628    *
629    * @param string $message
630    *   (optional) The error message to present to the user.
631    *
632    * @return $this
633    *
634    * @throws \BadMethodCallException
635    *   When the element instance was not constructed with a valid form state
636    *   object.
637    */
638   public function setError($message = '') {
639     if (!$this->formState) {
640       throw new \BadMethodCallException('The element instance must be constructed with a valid form state object to use this method.');
641     }
642     $this->formState->setError($this->array, $message);
643     return $this;
644   }
645
646   /**
647    * Adds an icon to button element based on its text value.
648    *
649    * @param array $icon
650    *   An icon render array.
651    *
652    * @return $this
653    *
654    * @see \Drupal\bootstrap\Bootstrap::glyphicon()
655    */
656   public function setIcon(array $icon = NULL) {
657     if ($this->isButton() && !Bootstrap::getTheme()->getSetting('button_iconize')) {
658       return $this;
659     }
660     if ($value = $this->getProperty('value', $this->getProperty('title'))) {
661       $icon = isset($icon) ? $icon : Bootstrap::glyphiconFromString($value);
662       $this->setProperty('icon', $icon);
663     }
664     return $this;
665   }
666
667   /**
668    * Sets the value for a property.
669    *
670    * @param string $name
671    *   The name of the property to set.
672    * @param mixed $value
673    *   The value of the property to set.
674    *
675    * @return $this
676    */
677   public function setProperty($name, $value) {
678     $this->array["#$name"] = $value instanceof Element ? $value->getArray() : $value;
679     return $this;
680   }
681
682   /**
683    * Converts an element description into a tooltip based on certain criteria.
684    *
685    * @param array|\Drupal\bootstrap\Utility\Element|NULL $target_element
686    *   The target element render array the tooltip is to be attached to, passed
687    *   by reference or an existing Element object. If not set, it will default
688    *   this Element instance.
689    * @param bool $input_only
690    *   Toggle determining whether or not to only convert input elements.
691    * @param int $length
692    *   The length of characters to determine if description is "simple".
693    *
694    * @return $this
695    */
696   public function smartDescription(&$target_element = NULL, $input_only = TRUE, $length = NULL) {
697     static $theme;
698     if (!isset($theme)) {
699       $theme = Bootstrap::getTheme();
700     }
701
702     // Determine if tooltips are enabled.
703     static $enabled;
704     if (!isset($enabled)) {
705       $enabled = $theme->getSetting('tooltip_enabled') && $theme->getSetting('forms_smart_descriptions');
706     }
707
708     // Immediately return if tooltip descriptions are not enabled.
709     if (!$enabled) {
710       return $this;
711     }
712
713     // Allow a different element to attach the tooltip.
714     /** @var Element $target */
715     if (is_object($target_element) && $target_element instanceof self) {
716       $target = $target_element;
717     }
718     elseif (isset($target_element) && is_array($target_element)) {
719       $target = new self($target_element, $this->formState);
720     }
721     else {
722       $target = $this;
723     }
724
725     // For "password_confirm" element types, move the target to the first
726     // textfield.
727     if ($target->isType('password_confirm')) {
728       $target = $target->pass1;
729     }
730
731     // Retrieve the length limit for smart descriptions.
732     if (!isset($length)) {
733       // Disable length checking by setting it to FALSE if empty.
734       $length = (int) $theme->getSetting('forms_smart_descriptions_limit') ?: FALSE;
735     }
736
737     // Retrieve the allowed tags for smart descriptions. This is primarily used
738     // for display purposes only (i.e. non-UI/UX related elements that wouldn't
739     // require a user to "click", like a link). Disable length checking by
740     // setting it to FALSE if empty.
741     static $allowed_tags;
742     if (!isset($allowed_tags)) {
743       $allowed_tags = array_filter(array_unique(array_map('trim', explode(',', $theme->getSetting('forms_smart_descriptions_allowed_tags') . '')))) ?: FALSE;
744     }
745
746     // Return if element or target shouldn't have "simple" tooltip descriptions.
747     $html = FALSE;
748     if (($input_only && !$target->hasProperty('input'))
749       // Ignore if the actual element has no #description set.
750       || !$this->hasProperty('description')
751
752       // Ignore if the target element already has a "data-toggle" attribute set.
753       || $target->hasAttribute('data-toggle')
754
755       // Ignore if the target element is #disabled.
756       || $target->hasProperty('disabled')
757
758       // Ignore if either the actual element or target element has an explicit
759       // #smart_description property set to FALSE.
760       || !$this->getProperty('smart_description', TRUE)
761       || !$target->getProperty('smart_description', TRUE)
762
763       // Ignore if the description is not "simple".
764       || !Unicode::isSimple($this->getProperty('description'), $length, $allowed_tags, $html)
765     ) {
766       // Set the both the actual element and the target element
767       // #smart_description property to FALSE.
768       $this->setProperty('smart_description', FALSE);
769       $target->setProperty('smart_description', FALSE);
770       return $this;
771     }
772
773     // Default attributes type.
774     $type = DrupalAttributes::ATTRIBUTES;
775
776     // Use #label_attributes for 'checkbox' and 'radio' elements.
777     if ($this->isType(['checkbox', 'radio'])) {
778       $type = DrupalAttributes::LABEL;
779     }
780     // Use #wrapper_attributes for 'checkboxes' and 'radios' elements.
781     elseif ($this->isType(['checkboxes', 'radios'])) {
782       $type = DrupalAttributes::WRAPPER;
783     }
784
785     // Retrieve the proper attributes array.
786     $attributes = $target->getAttributes($type);
787
788     // Set the tooltip attributes.
789     $attributes['title'] = $allowed_tags !== FALSE ? Xss::filter((string) $this->getProperty('description'), $allowed_tags) : $this->getProperty('description');
790     $attributes['data-toggle'] = 'tooltip';
791     if ($html || $allowed_tags === FALSE) {
792       $attributes['data-html'] = 'true';
793     }
794
795     // Remove the element description so it isn't (re-)rendered later.
796     $this->unsetProperty('description');
797
798     return $this;
799   }
800
801   /**
802    * Removes a property from the element.
803    *
804    * @param string $name
805    *   The name of the property to unset.
806    *
807    * @return $this
808    */
809   public function unsetProperty($name) {
810     unset($this->array["#$name"]);
811     return $this;
812   }
813
814 }