1 <?php
2
3 /*
4 * This file is part of the Brickrouge package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Brickrouge;
13
14 /**
15 * An HTML element.
16 *
17 * The `Element` class can create any kind of HTML element. It supports class names, dataset,
18 * children. It handles values and default values. It can decorate the HTML element with a label,
19 * a legend and a description.
20 *
21 * This is the base class to all element types.
22 *
23 * @property string $class Assigns a class name or set of class names to an element. Any number of
24 * elements may be assigned the same class name or names. Multiple class names must be separated
25 * by white space characters.
26 *
27 * @property Dataset $dataset The dataset property provides a convenient mapping to the
28 * data-* attributes on an element.
29 *
30 * @property string $id Assigns an identifier to an element. This identifier mush be unique in
31 * a document.
32 *
33 * @see http://dev.w3.org/html5/spec/Overview.html#embedding-custom-non-visible-data-with-the-data-attributes
34 */
35 class Element extends \ICanBoogie\Object implements \ArrayAccess, \IteratorAggregate, HTMLStringInterface
36 {
37 #
38 # special elements
39 #
40
41 /**
42 * Custom type used to create checkbox elements.
43 *
44 * @var string
45 */
46 const TYPE_CHECKBOX = '#checkbox';
47
48 /**
49 * Custom type used to create checkbox group elements.
50 *
51 * @var string
52 */
53 const TYPE_CHECKBOX_GROUP = '#checkbox-group';
54
55 /**
56 * Custom type used to create radio elements.
57 *
58 * @var string
59 */
60 const TYPE_RADIO = '#radio';
61
62 /**
63 * Custom type used to create radio group elements.
64 *
65 * @var string
66 */
67 const TYPE_RADIO_GROUP = '#radio-group';
68
69 #
70 # special tags
71 #
72
73 /**
74 * Used to define the children of an element.
75 *
76 * @var string
77 */
78 const CHILDREN = '#children';
79
80 /**
81 * Used to define the default value of an element.
82 *
83 * The default value is added to the dataset as 'default-value'.
84 *
85 * @var string
86 */
87 const DEFAULT_VALUE = '#default-value';
88
89 /**
90 * Used to define the description block of an element.
91 *
92 * @var string
93 *
94 * @see Element::decorate_with_description()
95 */
96 const DESCRIPTION = '#description';
97
98 /**
99 * Used to define the group of an element.
100 *
101 * @var string
102 */
103 const GROUP = '#group';
104
105 /**
106 * Used to define the groups that can be used by children elements.
107 *
108 * @var string
109 */
110 const GROUPS = '#groups';
111
112 /**
113 * Used to define the inline help of an element.
114 *
115 * @var string
116 *
117 * @see Element::decorate_with_inline_help()
118 */
119 const INLINE_HELP = '#inline-help';
120
121 /**
122 * Used to define the inner HTML of an element. If the value of the tag is null, the markup
123 * will be self-closing.
124 *
125 * @var string
126 */
127 const INNER_HTML = '#inner-html';
128
129 /**
130 * Used to define the label of an element.
131 *
132 * @var string
133 *
134 * @see Element::decorate_with_label()
135 */
136 const LABEL = '#label';
137
138 /**
139 * Used to define the position of the label. Possible positions are "before", "after" and
140 * "above". Defaults to "after".
141 *
142 * @var string
143 */
144 const LABEL_POSITION = '#label-position';
145 const LABEL_MISSING = '#label-missing';
146
147 /**
148 * Used to define the legend of an element. If the legend is defined the element is wrapped
149 * into a fieldset when it is rendered.
150 *
151 * @var string
152 *
153 * @see Element::decorate_with_legend()
154 */
155 const LEGEND = '#element-legend';
156
157 /**
158 * Used to define the required state of an element.
159 *
160 * @var string
161 *
162 * @see Form::validate()
163 * @see http://dev.w3.org/html5/spec/Overview.html#the-required-attribute
164 */
165 const REQUIRED = 'required';
166
167 /**
168 * Used to define the options of the following element types: "select",
169 * {@link TYPE_RADIO_GROUP} and {@link TYPE_CHECKBOX_GROUP}.
170 *
171 * @var string
172 */
173 const OPTIONS = '#options';
174
175 /**
176 * Used to define which options are disabled.
177 *
178 * @var string
179 */
180 const OPTIONS_DISABLED = '#options-disabled';
181
182 /**
183 * Used to define the state of the element: "success", "warning" or "error".
184 *
185 * @var string
186 */
187 const STATE = '#state';
188
189 /**
190 * Used to define the validator of an element. The validator is defined using an array made of
191 * a callback and a possible userdata array.
192 *
193 * @var string
194 */
195 const VALIDATOR = '#validator';
196 const VALIDATOR_OPTIONS = '#validator-options';
197
198 /**
199 * Use to define the weight of an element. This attribute can be used to reorder children when
200 * a parent element is rendered.
201 *
202 * @var string
203 *
204 * @see Element::get_ordered_children()
205 */
206 const WEIGHT = '#weight';
207
208 /**
209 * The name of the Javascript constructor that should be used to construct the widget.
210 *
211 * @var string
212 */
213 const WIDGET_CONSTRUCTOR = '#widget-constructor';
214
215 static private $inputs = array('button', 'form', 'input', 'option', 'select', 'textarea');
216 static private $has_attribute_disabled = array('button', 'input', 'optgroup', 'option', 'select', 'textarea');
217 static private $has_attribute_value = array('button', 'input', 'option');
218 static private $has_attribute_required = array('input', 'select', 'textarea');
219 static private $handled_assets = array();
220
221 static protected function handle_assets()
222 {
223 $class = get_called_class();
224
225 if (isset(self::$handled_assets[$class]))
226 {
227 return;
228 }
229
230 self::$handled_assets[$class] = true;
231
232 static::add_assets(get_document());
233 }
234
235 /**
236 * Adds assets to the document.
237 *
238 * @param Document $document
239 */
240 static protected function add_assets(Document $document)
241 {
242
243 }
244
245 /**
246 * Next available auto element id index.
247 *
248 * @var int
249 */
250 static protected $auto_element_id = 1;
251
252 /**
253 * Returns a unique element id string.
254 *
255 * @return string
256 */
257 static public function auto_element_id()
258 {
259 return 'autoid--' . self::$auto_element_id++;
260 }
261
262 /**
263 * Type if the element, as provided during {@link __construct()}.
264 *
265 * @var string
266 */
267 public $type;
268
269 /**
270 * Tag name of the rendered HTML element.
271 *
272 * @var string
273 */
274 public $tag_name;
275
276 /**
277 * An array containing the children of the element.
278 *
279 * @var array
280 */
281 public $children = array();
282
283 /**
284 * Tags of the element, including HTML and special attributes.
285 *
286 * @var array[string]mixed
287 */
288 protected $tags = array();
289
290 /**
291 * Inner HTML of the element.
292 *
293 * @var string|null
294 *
295 * @see Element::render_inner_html()
296 */
297 protected $inner_html;
298
299 /**
300 * @param string $type Type of the element, it can be one of the custom types (`TYPE_*`) or
301 * any HTML type.
302 *
303 * @param array $attributes HTML and custom attributes.
304 */
305 public function __construct($type, $attributes=array())
306 {
307 $this->type = $type;
308
309 #
310 # children first
311 #
312
313 if (!empty($attributes[self::CHILDREN]))
314 {
315 $this->children = array();
316 $this->adopt($attributes[self::CHILDREN]);
317
318 unset($attributes[self::CHILDREN]);
319 }
320
321 #
322 # prepare special elements
323 #
324
325 switch ((string) $type)
326 {
327 case self::TYPE_CHECKBOX:
328 case self::TYPE_RADIO:
329 {
330 static $translate = array
331 (
332 self::TYPE_CHECKBOX => array('input', 'checkbox'),
333 self::TYPE_RADIO => array('input', 'radio'),
334 );
335
336 $this->tag_name = $translate[$type][0];
337 $attributes['type'] = $translate[$type][1];
338 }
339 break;
340
341 case self::TYPE_CHECKBOX_GROUP:
342 {
343 $this->tag_name = 'div';
344 }
345 break;
346
347 case self::TYPE_RADIO_GROUP:
348 {
349 $this->tag_name = 'div';
350 }
351 break;
352
353 case 'textarea':
354 {
355 $this->tag_name = 'textarea';
356
357 $attributes += array('rows' => 10, 'cols' => 76);
358 }
359 break;
360
361 default:
362 {
363 $this->tag_name = $type;
364 }
365 break;
366 }
367
368 foreach ($attributes as $attribute => $value)
369 {
370 $this[$attribute] = $value;
371 }
372
373 switch ((string) $this->type)
374 {
375 case self::TYPE_CHECKBOX_GROUP: $this->add_class('checkbox-group'); break;
376 case self::TYPE_RADIO_GROUP: $this->add_class('radio-group'); break;
377 }
378 }
379
380 /**
381 * Checks is an attribute is set.
382 *
383 * @param string $attribute
384 *
385 * @return bool
386 */
387 public function offsetExists($attribute)
388 {
389 return isset($this->tags[$attribute]);
390 }
391
392 /**
393 * Returns the value of an attribute.
394 *
395 * @param string $attribute
396 * @param null $default The default value of the attribute.
397 *
398 * @return mixed|null The value of the attribute, or null if is not set.
399 */
400 public function offsetGet($attribute, $default=null)
401 {
402 return isset($this->tags[$attribute]) ? $this->tags[$attribute] : $default;
403 }
404
405 /**
406 * Sets the value of an attribute.
407 *
408 * @param string $attribute The attribute.
409 * @param mixed $value The value of the attribute.
410 */
411 public function offsetSet($attribute, $value)
412 {
413 switch ($attribute)
414 {
415 case self::CHILDREN:
416 {
417 $this->children = array();
418 $this->adopt($value);
419 }
420 break;
421
422 case self::INNER_HTML:
423 {
424 $this->inner_html = $value;
425 }
426 break;
427
428 case 'class':
429 {
430 $this->class = $value;
431 }
432 break;
433
434 case 'id':
435 {
436 unset($this->id);
437 }
438 break;
439 }
440
441 $this->tags[$attribute] = $value;
442 }
443
444 /**
445 * Removes an attribute.
446 *
447 * @param string $attribute The name of the attribute.
448 */
449 public function offsetUnset($attribute)
450 {
451 unset($this->tags[$attribute]);
452 }
453
454 /**
455 * Returns an iterator.
456 *
457 * @return Iterator
458 */
459 public function getIterator()
460 {
461 return new Iterator($this);
462 }
463
464 private $_dataset;
465
466 /**
467 * Returns the {@link Dataset} of the element.
468 *
469 * @return Dataset
470 */
471 protected function get_dataset()
472 {
473 if (!$this->_dataset)
474 {
475 $this->_dataset = new Dataset($this);
476 }
477
478 return $this->_dataset;
479 }
480
481 /**
482 * Sets the datset of the element.
483 *
484 * @param array|Dataset $properties
485 *
486 * @return Dataset
487 */
488 protected function set_dataset($dataset)
489 {
490 if (!($dataset instanceof Dataset))
491 {
492 $dataset = new Dataset($this, $dataset);
493 }
494
495 $this->_dataset = $dataset;
496 }
497
498 protected function get_attributes()
499 {
500 return $this->tags;
501 }
502
503 /**
504 * Returns the element's id.
505 *
506 * If the element's id is empty, a unique id is generated and added to its tags.
507 *
508 * @return string
509 */
510 protected function lazy_get_id()
511 {
512 $id = $this['id'];
513
514 if (!$id)
515 {
516 $name = $this['name'];
517
518 if ($name)
519 {
520 $id = 'autoid--' . normalize($name);
521 }
522 else
523 {
524 $id = self::auto_element_id();
525 }
526
527 $this['id'] = $id;
528 }
529
530 return $id;
531 }
532
533 /**
534 * Class names used to compose the value of the `class` attribute.
535 *
536 * @var array
537 */
538 protected $class_names = array();
539
540 /**
541 * Returns the value of the "class" attribute.
542 *
543 * @return string
544 */
545 protected function get_class()
546 {
547 $class_names = $this->alter_class_names($this->class_names);
548
549 return $this->render_class($class_names);
550 }
551
552 /**
553 * Sets the value of the "class" attribute.
554 *
555 * @param string $class
556 */
557 protected function set_class($class)
558 {
559 $names = explode(' ', trim($class));
560 $names = array_map('trim', $names);
561
562 $this->class_names = array_combine($names, array_fill(0, count($names), true));
563 }
564
565 /**
566 * Adds a class name to the "class" attribute.
567 *
568 * @param $class
569 *
570 * @return Element
571 */
572 public function add_class($class)
573 {
574 $this->class_names[$class] = true;
575
576 return $this;
577 }
578
579 /**
580 * Removes a class name from the `class` attribute.
581 *
582 * @param $class
583 *
584 * @return Element
585 */
586 public function remove_class($class)
587 {
588 unset($this->class_names[$class]);
589
590 return $this;
591 }
592
593 /**
594 * Checks if a class name is defined in the `class` attribute.
595 *
596 * @param string $class_name
597 *
598 * @return boolean true if the element has the class name, false otherwise.
599 */
600 public function has_class($class_name)
601 {
602 return isset($this->class_names[$class_name]);
603 }
604
605 /**
606 * Alters the class names.
607 *
608 * This method is invoked before the class names are rendered.
609 *
610 * @param array $class_names
611 *
612 * @return array
613 */
614 protected function alter_class_names(array $class_names)
615 {
616 return $class_names;
617 }
618
619 /**
620 * Renders the `class` attribute value.
621 *
622 * @param array $class_names An array of class names. Each key/value pair describe a class
623 * name. The key is the identifier of the class name, the value is its value. If the value is
624 * empty then the class name is discarted. If the value is `true` the identifier of the class
625 * name is used as value.
626 *
627 * @return string
628 */
629 protected function render_class(array $class_names)
630 {
631 $class = '';
632 $class_names = array_filter($class_names);
633
634 foreach ($class_names as $name => $value)
635 {
636 if ($value === true)
637 {
638 $value = $name;
639 }
640
641 $class .= ' ' . $value;
642 }
643
644 return substr($class, 1);
645 }
646
647 /**
648 * Add a child or children to the element.
649 *
650 * If the children are provided in an array, each key/value pair defines the name of a child
651 * and the child itself. If the key is not numeric it is considered as the child's name and is
652 * used to set its `name` attribute, unless the attribute is already defined.
653 *
654 * @param string|Element|array $child The child or children to add.
655 * @param string|Element $other[optional] Other child.
656 */
657 public function adopt($child, $other=null)
658 {
659 if (func_num_args() > 1)
660 {
661 $child = func_get_args();
662 }
663
664 if (is_array($child))
665 {
666 $children = $child;
667
668 foreach($children as $name => $child)
669 {
670 if (is_numeric($name))
671 {
672 $this->children[] = $child;
673 }
674 else
675 {
676 if ($child instanceof self && $child['name'] === null)
677 {
678 $child['name'] = $name;
679 }
680
681 $this->children[$name] = $child;
682 }
683 }
684 }
685 else
686 {
687 $this->children[] = $child;
688 }
689 }
690
691 /**
692 * Returns the children of the element ordered according to their weight.
693 *
694 * @return array[int]Element|string
695 */
696 public function get_ordered_children()
697 {
698 if (!$this->children)
699 {
700 return array();
701 }
702
703 $by_weight = array();
704 $with_relative_positions = array();
705
706 foreach ($this->children as $name => $child)
707 {
708 $weight = is_object($child) ? $child[self::WEIGHT] : 0;
709
710 if (is_string($weight) && !is_numeric($weight)) // FIXME: is is_numeric() not enough ?
711 {
712 $with_relative_positions[] = $child;
713
714 continue;
715 }
716
717 $by_weight[(int) $weight][$name] = $child;
718 }
719
720 if (count($by_weight) == 1 && !$with_relative_positions)
721 {
722 return $this->children;
723 }
724
725 ksort($by_weight);
726
727 $rc = array();
728
729 foreach ($by_weight as $children)
730 {
731 $rc += $children;
732 }
733
734 #
735 # now we deal with the relative positions
736 #
737
738 if ($with_relative_positions)
739 {
740 foreach ($with_relative_positions as $child)
741 {
742 list($target, $position) = explode(':', $child[self::WEIGHT]) + array(1 => 'after');
743
744 $rc = array_insert($rc, $target, $child, $child['name'], $position == 'after');
745 }
746 }
747
748 return $rc;
749 }
750
751 /**
752 * Returns the HTML representation of a child element.
753 *
754 * @param Element|string $child
755 *
756 * @return string
757 */
758 protected function render_child($child)
759 {
760 return (string) $child;
761 }
762
763 /**
764 * Renders the children of the element into a HTML string.
765 *
766 * @param array $children
767 *
768 * @return string
769 */
770 protected function render_children(array $children)
771 {
772 $html = '';
773
774 foreach ($children as $child)
775 {
776 $html .= $this->render_child($child);
777 }
778
779 return $html;
780 }
781
782 /**
783 * Returns the HTML representation of the element's content.
784 *
785 * The children of the element are ordered before they are rendered using the
786 * {@link render_children()} method.
787 *
788 * According to their types, the following methods can be invoked to render the inner HTML
789 * of elements:
790 *
791 * - {@link render_inner_html_for_select}
792 * - {@link render_inner_html_for_textarea}
793 * - {@link render_inner_html_for_checkbox_group}
794 * - {@link render_inner_html_for_radio_group}
795 *
796 * @return string|null The content of the element. The element is to be considered
797 * _self-closing_ if `null` is returned.
798 */
799 protected function render_inner_html()
800 {
801 $html = null;
802
803 switch ($this->type)
804 {
805 case 'select': $html = $this->render_inner_html_for_select(); break;
806 case 'textarea': $html = $this->render_inner_html_for_textarea(); break;
807 case self::TYPE_CHECKBOX_GROUP: $html = $this->render_inner_html_for_checkbox_group(); break;
808 case self::TYPE_RADIO_GROUP: $html = $this->render_inner_html_for_radio_group(); break;
809 }
810
811 $children = $this->get_ordered_children();
812
813 if ($children)
814 {
815 $html .= $this->render_children($children);
816 }
817 else if ($this->inner_html !== null)
818 {
819 $html = $this->inner_html;
820 }
821
822 return $html;
823 }
824
825 /**
826 * Renders inner HTML of `SELECT` elements.
827 *
828 * @return string
829 */
830 protected function render_inner_html_for_select()
831 {
832 #
833 # get the name and selected value for our children
834 #
835
836 $selected = $this['value'];
837
838 if ($selected === null)
839 {
840 $selected = $this[self::DEFAULT_VALUE];
841 }
842
843 #
844 # this is the 'template' child
845 #
846
847 $dummy_option = new Element('option');
848
849 #
850 # create the inner content of our element
851 #
852
853 $html = '';
854
855 $options = $this[self::OPTIONS] ?: array();
856 $disabled = $this[self::OPTIONS_DISABLED];
857
858 foreach ($options as $value => $label)
859 {
860 if ($label instanceof self)
861 {
862 $option = $label;
863 }
864 else
865 {
866 $option = $dummy_option;
867
868 if ($label || is_numeric($label))
869 {
870 $label = escape($label);
871 }
872 else
873 {
874 $label = ' ';
875 }
876
877 $option->inner_html = $label;
878 }
879
880 #
881 # value is casted to a string so that we can handle null value and compare '0' with 0
882 #
883
884 $option['value'] = $value;
885 $option['selected'] = (string) $value === (string) $selected;
886 $option['disabled'] = !empty($disabled[$value]);
887
888 $html .= $option;
889 }
890
891 return $html;
892 }
893
894 /**
895 * Renders the inner HTML of `TEXTAREA` elements.
896 *
897 * @return string
898 */
899 protected function render_inner_html_for_textarea()
900 {
901 $value = $this['value'];
902
903 if ($value === null)
904 {
905 $value = $this[self::DEFAULT_VALUE];
906 }
907
908 return escape($value);
909 }
910
911 /**
912 * Renders inner HTML of {@link TYPE_CHECKBOX_GROUP} custom elements.
913 *
914 * @return string
915 */
916 protected function render_inner_html_for_checkbox_group()
917 {
918 #
919 # get the name and selected value for our children
920 #
921
922 $name = $this['name'];
923 $selected = (array) $this['value'] ?: $this[self::DEFAULT_VALUE];
924 $disabled = $this['disabled'] ?: false;
925 $readonly = $this['readonly'] ?: false;
926
927 #
928 # this is the 'template' child
929 #
930
931 $child = new Element
932 (
933 'input', array
934 (
935 'type' => 'checkbox',
936 'readonly' => $readonly
937 )
938 );
939
940 #
941 # create the inner content of our element
942 #
943
944 $html = '';
945 $disableds = $this[self::OPTIONS_DISABLED];
946
947 foreach ($this[self::OPTIONS] as $option_name => $label)
948 {
949 $child[self::LABEL] = $label;
950 $child['name'] = $name . '[' . $option_name . ']';
951 $child['checked'] = !empty($selected[$option_name]);
952 $child['disabled'] = $disabled || !empty($disableds[$option_name]);
953 $child['data-key'] = $option_name;
954 $child['data-name'] = $name;
955
956 $html .= $child;
957 }
958
959 return $html;
960 }
961
962 /**
963 * Renders inner HTML of {@link TYPE_RADIO_GROUP} custom elements.
964 *
965 * @return string
966 */
967 protected function render_inner_html_for_radio_group()
968 {
969 #
970 # get the name and selected value for our children
971 #
972
973 $name = $this['name'];
974 $selected = $this['value'];
975
976 if ($selected === null)
977 {
978 $selected = $this[self::DEFAULT_VALUE];
979 }
980
981 $disabled = $this['disabled'] ?: false;
982 $readonly = $this['readonly'] ?: false;
983
984 #
985 # this is the 'template' child
986 #
987
988 $child = new Element
989 (
990 'input', array
991 (
992 'type' => 'radio',
993 'name' => $name,
994 'readonly' => $readonly
995 )
996 );
997
998 #
999 # create the inner content of our element
1000 #
1001 # add our options as children
1002 #
1003
1004 $html = '';
1005 $disableds = $this[self::OPTIONS_DISABLED];
1006
1007 foreach ($this[self::OPTIONS] as $value => $label)
1008 {
1009 if ($label && !is_object($label) && $label{0} == '.')
1010 {
1011 $label = t(substr($label, 1), array(), array('scope' => 'element.option'));
1012 }
1013
1014 $child[self::LABEL] = $label;
1015 $child['value'] = $value;
1016 $child['checked'] = (string) $value === (string) $selected;
1017 $child['disabled'] = $disabled || !empty($disableds[$value]);
1018
1019 $html .= $child;
1020 }
1021
1022 return $html;
1023 }
1024
1025 /**
1026 * Alters the provided attributes.
1027 *
1028 * - The `value`, `required`, `disabled` and `name` attributes are discarded if they are not
1029 * supported by the element type.
1030 *
1031 * - The `title` attribute is translated within the scope `element.title`.
1032 *
1033 * - The `checked` attribute of elements of type {@link TYPE_CHECKBOX} is set to `true` if
1034 * their {@link DEFAULT_VALUE} attribute is not empty and their `checked` attribute is not
1035 * defined (`null`).
1036 *
1037 * - The `value` attribute of `INPUT` and `BUTTON` elements is altered if the
1038 * {@link DEFAULT_VALUE} attribute is defined and the `value` attribute is not (`null`).
1039 *
1040 * @param array $attributes
1041 *
1042 * @return array The altered attributes.
1043 */
1044 protected function alter_attributes(array $attributes)
1045 {
1046 $tag_name = $this->tag_name;
1047
1048 foreach ($attributes as $attribute => $value)
1049 {
1050 if ($attribute == 'value' && !in_array($tag_name, self::$has_attribute_value))
1051 {
1052 unset($attributes[$attribute]);
1053
1054 continue;
1055 }
1056
1057 if ($attribute == 'required' && !in_array($tag_name, self::$has_attribute_required))
1058 {
1059 unset($attributes[$attribute]);
1060
1061 continue;
1062 }
1063
1064 if ($attribute == 'disabled' && !in_array($tag_name, self::$has_attribute_disabled))
1065 {
1066 unset($attributes[$attribute]);
1067
1068 continue;
1069 }
1070
1071 if ($attribute == 'name' && !in_array($tag_name, self::$inputs))
1072 {
1073 unset($attributes[$attribute]);
1074
1075 continue;
1076 }
1077
1078 if ($attribute == 'title')
1079 {
1080 $attributes[$attribute] = t($value, array(), array('scope' => 'element.title'));
1081 }
1082 }
1083
1084 #
1085 # value/checked
1086 #
1087
1088 if ($this->type == self::TYPE_CHECKBOX && $this['checked'] === null)
1089 {
1090 $attributes['checked'] = !!$this[self::DEFAULT_VALUE];
1091 }
1092 else if (($tag_name == 'input' || $tag_name == 'button') && $this['value'] === null)
1093 {
1094 $attributes['value'] = $this[self::DEFAULT_VALUE];
1095 }
1096
1097 return $attributes;
1098 }
1099
1100 /**
1101 * Renders attributes.
1102 *
1103 * Attributes with `false` or `null` values as well as custom attributes are discarted.
1104 * Attributes with the `true` value are translated to XHTML standard e.g. readonly="readonly".
1105 *
1106 * @param array $attributes
1107 *
1108 * @return string
1109 *
1110 * @throws \InvalidArgumentException if the value is an array or an object that doesn't
1111 * implement the `toString()` method.
1112 */
1113 protected function render_attributes(array $attributes)
1114 {
1115 $html = '';
1116
1117 foreach ($attributes as $attribute => $value)
1118 {
1119 if ($value === false || $value === null || $attribute{0} == '#')
1120 {
1121 continue;
1122 }
1123 else if ($value === true)
1124 {
1125 $value = $attribute;
1126 }
1127 else if (is_array($value) || (is_object($value) && !method_exists($value, '__toString')))
1128 {
1129 throw new \InvalidArgumentException(format('Invalid value for attribute %attribute: :value', array('attribute' => $attribute, 'value' => $value)));
1130 }
1131
1132 $html .= ' ' . $attribute . '="' . (is_numeric($value) ? $value : escape($value)) . '"';
1133 }
1134
1135 return $html;
1136 }
1137
1138 /**
1139 * Alters the dataset.
1140 *
1141 * The method is invoked before the dataset is rendered.
1142 *
1143 * The method might add the `default-value` and `widget-constructor` keys.
1144 *
1145 * @param array $dataset
1146 *
1147 * @return array
1148 */
1149 protected function alter_dataset(array $dataset)
1150 {
1151 if ((in_array($this->tag_name, self::$has_attribute_value) || $this->tag_name == 'textarea')
1152 && $this['data-default-value'] === null)
1153 {
1154 $dataset['default-value'] = $this[self::DEFAULT_VALUE];
1155 }
1156
1157 if (!isset($dataset['widget-constructor']))
1158 {
1159 $dataset['widget-constructor'] = $this[self::WIDGET_CONSTRUCTOR];
1160 }
1161
1162 return $dataset;
1163 }
1164
1165 /**
1166 * Renders dataset.
1167 *
1168 * The dataset is rendered as a series of "data-*" attributes. Values of type array are
1169 * encoded using the {@link json_encode()} function. Attributes with null values are discarted,
1170 * but unlike classic attributes boolean values are converted to integers.
1171 *
1172 * @param array $dataset
1173 *
1174 * @return string
1175 */
1176 protected function render_dataset(array $dataset)
1177 {
1178 $rc = '';
1179
1180 foreach ($dataset as $name => $value)
1181 {
1182 if (is_array($value))
1183 {
1184 $value = json_encode($value);
1185 }
1186
1187 if ($value === null)
1188 {
1189 continue;
1190 }
1191 else if ($value === false)
1192 {
1193 $value = 0;
1194 }
1195
1196 $rc .= ' data-' . $name . '="' . (is_numeric($value) ? $value : escape($value)) . '"';
1197 }
1198
1199 return $rc;
1200 }
1201
1202 /**
1203 * Returns the HTML representation of the element and its contents.
1204 *
1205 * The attributes are filtered before they are rendered. The attributes with a `false` or
1206 * `null` value are discarded as well as custom attributes, attributes that start with the has
1207 * sign "#". The dataset properties—attributes starting with "data-*"—are extracted to be
1208 * handled separately.
1209 *
1210 * The {@link alter_attributes()} method is invoked to alter the attributes and the
1211 * {@link render_attributes()} method is invoked to render them.
1212 *
1213 * The {@link alter_dataset()} method is invoked to alter the dataset and the
1214 * {@link render_dataset()} method is invoked to render them.
1215 *
1216 * If the inner HTML is null the element is self-closing.
1217 *
1218 * Note: The inner HTML is rendered before the outer HTML.
1219 *
1220 * @return string
1221 */
1222 protected function render_outer_html()
1223 {
1224 $inner = $this->render_inner_html();
1225 $attributes = array();
1226 $dataset = array();
1227
1228 foreach ($this->tags as $attribute => $value)
1229 {
1230 if (strpos($attribute, 'data-') === 0)
1231 {
1232 $dataset[substr($attribute, 5)] = $value;
1233 }
1234 else
1235 {
1236 $attributes[$attribute] = $value;
1237 }
1238 }
1239
1240 $class = $this->class;
1241
1242 if ($class)
1243 {
1244 $attributes['class'] = $class;
1245 }
1246
1247 $html = '<'
1248 . $this->tag_name
1249 . $this->render_attributes($this->alter_attributes($attributes))
1250 . $this->render_dataset($this->alter_dataset($dataset));
1251
1252 #
1253 # if the inner HTML of the element is `null`, the element is self closing.
1254 #
1255
1256 if ($inner === null)
1257 {
1258 $html .= ' />';
1259 }
1260 else
1261 {
1262 $html .= '>' . $inner . '</' . $this->tag_name . '>';
1263 }
1264
1265 return $html;
1266 }
1267
1268 /**
1269 * Decorates the specified HTML.
1270 *
1271 * The HTML can be decorated by following attributes:
1272 *
1273 * - A label defined by the {@link LABEL} special attribute. The {@link decorate_with_label()}
1274 * method is used to decorate the HTML with the label.
1275 *
1276 * - An inline help defined by the {@link INLINE_HELP} special attribute. The
1277 * {@link decorate_with_inline_help()} method is used to decorate the HTML with the inline
1278 * help.
1279 *
1280 * - A description (or help block) defined by the {@link DESCRIPTION} special attribute. The
1281 * {@link decorate_with_description()} method is used to decorate the HTML with the
1282 * description.
1283 *
1284 * - A legend defined by the {@link LEGEND} special attribute. The
1285 * {@link decorate_with_label()} method is used to decorate the HTML with the legend.
1286 *
1287 * @param string $html The HTML to decorate.
1288 *
1289 * @return string The decorated HTML.
1290 */
1291 protected function decorate($html)
1292 {
1293 #
1294 # add label
1295 #
1296
1297 $label = $this[self::LABEL];
1298
1299 if ($label || $label === '0')
1300 {
1301 $label = t($label, array(), array('scope' => 'element.label'));
1302 $html = $this->decorate_with_label($html, $label);
1303 }
1304
1305 #
1306 # add inline help
1307 #
1308
1309 $help = $this[self::INLINE_HELP];
1310
1311 if ($help)
1312 {
1313 $help = t($help, array(), array('scope' => 'element.inline_help'));
1314 $html = $this->decorate_with_inline_help($html, $help);
1315 }
1316
1317 #
1318 # add description
1319 #
1320
1321 $description = $this[self::DESCRIPTION];
1322
1323 if ($description)
1324 {
1325 $description = t($description, array(), array('scope' => 'element.description'));
1326 $html = $this->decorate_with_description($html, $description);
1327 }
1328
1329 #
1330 # add legend
1331 #
1332
1333 $legend = $this[self::LEGEND];
1334
1335 if ($legend)
1336 {
1337 $legend = t($legend, array(), array('scope' => 'element.legend'));
1338 $html = $this->decorate_with_legend($html, $legend);
1339 }
1340
1341 return $html;
1342 }
1343
1344 /**
1345 * Decorates the specified HTML with specified label.
1346 *
1347 * The position of the label is defined using the {@link T_LABEL_POSITION} tag
1348 *
1349 * @param string $html
1350 * @param string $label The label as defined by the {@link T_LABEL} tag.
1351 *
1352 * @return string
1353 */
1354 protected function decorate_with_label($html, $label)
1355 {
1356 $class = 'element-label';
1357
1358 if ($this[self::REQUIRED])
1359 {
1360 $class .= ' required';
1361 }
1362
1363 switch ($this[self::LABEL_POSITION] ?: 'after')
1364 {
1365 case 'above': return <<<EOT
1366 <label class="$class above">$label</label>
1367 $html
1368 EOT;
1369
1370 case 'below': return <<<EOT
1371 $html
1372 <label class="$class below">$label</label>
1373 EOT;
1374
1375 case 'before': return <<<EOT
1376 <label class="$class wrapping before"><span class="label-text">$label</span> $html</label>
1377 EOT;
1378
1379 case 'after':
1380 default: return <<<EOT
1381 <label class="$class wrapping after">$html <span class="label-text">$label</span></label>
1382 EOT;
1383 }
1384 }
1385
1386 /**
1387 * Decorates the specified HTML with a fieldset and the specified legend.
1388 *
1389 * @param string $html
1390 * @param string $legend
1391 *
1392 * @return string
1393 */
1394 protected function decorate_with_legend($html, $legend)
1395 {
1396 return '<fieldset><legend>' . $legend . '</legend>' . $html . '</fieldset>';
1397 }
1398
1399 /**
1400 * Decorates the specified HTML with an inline help element.
1401 *
1402 * @param string $html
1403 * @param string $help
1404 *
1405 * @return string
1406 */
1407 protected function decorate_with_inline_help($html, $help)
1408 {
1409 return $html . '<span class="help-inline">' . $help . '</span>';
1410 }
1411
1412 /**
1413 * Decorates the specified HTML with the specified description.
1414 *
1415 * @param string $html
1416 * @param string $description
1417 *
1418 * @return string
1419 */
1420 protected function decorate_with_description($html, $description)
1421 {
1422 return $html . '<div class="element-description help-block">' . $description . '</div>';
1423 }
1424
1425 /**
1426 * Renders the element into an HTML string.
1427 *
1428 * Before the element is rendered the method {@link handle_assets()} is invoked.
1429 *
1430 * The inner HTML is rendered by the {@link render_inner_html()} method. The outer HTML is
1431 * rendered by the {@link render_outer_html()} method. Finaly, the HTML is decorated by
1432 * the {@link decorate()} method.
1433 *
1434 * If the {@link ElementIsEmpty} exception is caught during the rendering
1435 * an empty string is returned.
1436 *
1437 * @return string The HTML representation of the object
1438 */
1439 public function render()
1440 {
1441 if (get_class($this) != __CLASS__)
1442 {
1443 static::handle_assets();
1444 }
1445
1446 try
1447 {
1448 $html = $this->render_outer_html();
1449
1450 return $this->decorate($html);
1451 }
1452 catch (ElementIsEmpty $e)
1453 {
1454 return '';
1455 }
1456 }
1457
1458 /**
1459 * Renders the element into an HTML string.
1460 *
1461 * The method {@link render()} is invoked to render the element.
1462 *
1463 * If an exception is thrown during the rendering it is rendered using the
1464 * {@link render_exception()} function and returned.
1465 *
1466 * @return string The HTML representation of the object
1467 */
1468 public function __toString()
1469 {
1470 try
1471 {
1472 return $this->render();
1473 }
1474 catch (\Exception $e)
1475 {
1476 return render_exception($e);
1477 }
1478 }
1479
1480 /**
1481 * Validates the specified value.
1482 *
1483 * This function uses the validator defined using the {@link VALIDATOR} special attribute to
1484 * validate its value.
1485 *
1486 * @param $value
1487 * @param \ICanBoogie\Errors $errors
1488 *
1489 * @return boolean `true` if the validation succeed, `false` otherwise.
1490 */
1491 public function validate($value, \ICanBoogie\Errors $errors)
1492 {
1493 $validator = $this[self::VALIDATOR];
1494 $options = $this[self::VALIDATOR_OPTIONS];
1495
1496 if ($validator)
1497 {
1498 list($callback, $params) = $validator + array(1 => array());
1499
1500 return call_user_func($callback, $errors, $this, $value, $params);
1501 }
1502
1503 #
1504 # default validator
1505 #
1506
1507 if (!$options)
1508 {
1509 return true;
1510 }
1511
1512 switch ($this->type)
1513 {
1514 case self::TYPE_CHECKBOX_GROUP:
1515 {
1516 if (isset($options['max-checked']))
1517 {
1518 $limit = $options['max-checked'];
1519
1520 if (count($value) > $limit)
1521 {
1522 $errors[$this->name] = t('Le nombre de choix possible pour le champ %name est limité à :limit', array
1523 (
1524 'name' => Form::select_element_label($this),
1525 'limit' => $limit
1526 ));
1527
1528 return false;
1529 }
1530 }
1531 }
1532 break;
1533 }
1534
1535 return true;
1536 }
1537 }
1538
1539 /**
1540 * Exception thrown when one wants to cancel the whole rendering of an empty element. The
1541 * {@link Element} class takes care of this special case and instead of rendering the exception
1542 * only returns an empty string as the result of its {@link Element::render()} method.
1543 */
1544 class ElementIsEmpty extends \Exception
1545 {
1546 public function __construct($message="The element is empty.", $code=200, \Exception $previous=null)
1547 {
1548 parent::__construct($message, $code, $previous);
1549 }
1550 }