Completed
Push — master ( 664764...a07461 )
by Rasmus
04:13
created

InputRenderer::labelFor()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 18
ccs 9
cts 9
cp 1
rs 9.2
cc 4
eloc 9
nc 4
nop 3
crap 4
1
<?php
2
3
namespace mindplay\kissform;
4
5
use mindplay\kissform\Facets\FieldInterface;
6
use RuntimeException;
7
8
/**
9
 * This class renders and populates input elements for use in forms,
10
 * by consuming property-information provided by {@see Field} objects,
11
 * and populating them with state from an {@see InputModel}.
12
 *
13
 * Conventions for method-names in this class:
14
 *
15
 *   * `get_()` and `is_()` methods provide raw information about Fields
16
 * 
17
 *   * `render_()` methods delegate rendering to {@see Field::renderInput} implementations.
18
 * 
19
 *   * `_For()` methods (such as `inputFor()`) render low-level HTML tags (with state) for Fields
20
 * 
21
 *   * Verb methods like `visit`, `merge` and `escape` perform various relevant actions
22
 * 
23
 *   * Noun methods like `tag`, `attrs` and `label` create low-level HTML tags
24
 *
25
 */
26
class InputRenderer
27
{
28
    /**
29
     * @var string HTML encoding charset
30
     */
31
    public $encoding = 'UTF-8';
32
33
    /**
34
     * @var bool if true, use long form XHTML for value-less attributes (e.g. disabled="disabled")
35
     *
36
     * @see attrs()
37
     */
38
    public $xhtml = false;
39
40
    /**
41
     * @var InputModel input model
42
     */
43
    public $model;
44
45
    /**
46
     * @var string|string[]|null form element collection name(s)
47
     */
48
    public $collection_name;
49
50
    /**
51
     * @var string|null form element id-attribute prefix (or null, to bypass id-attribute generation)
52
     */
53
    public $id_prefix;
54
55
    /**
56
     * @var string CSS class name applied to all form controls
57
     *
58
     * @see inputFor()
59
     */
60
    public $input_class = 'form-control';
61
62
    /**
63
     * @var string CSS class name added to labels
64
     *
65
     * @see labelFor()
66
     */
67
    public $label_class = 'control-label';
68
69
    /**
70
     * @var string suffix to append to all labels (e.g. ":")
71
     *
72
     * @see labelFor()
73
     */
74
    public $label_suffix = '';
75
76
    /**
77
     * @var string CSS class-name added to required fields
78
     *
79
     * @see groupFor()
80
     */
81
    public $required_class = 'required';
82
83
    /**
84
     * @var string CSS class-name added to fields with error state
85
     *
86
     * @see groupFor()
87
     */
88
    public $error_class = 'has-error';
89
90
    /**
91
     * @var string group tag name (e.g. "div", "fieldset", etc.; defaults to "div")
92
     *
93
     * @see groupFor()
94
     * @see endGroup()
95
     */
96
    public $group_tag = 'div';
97
98
    /**
99
     * @var array default attributes to be added to opening control-group tags
100
     *
101
     * @see groupFor()
102
     */
103
    public $group_attrs = ['class' => 'form-group'];
104
105
    /**
106
     * @var string[] map where Field name => label override
107
     */
108
    protected $labels = [];
109
110
    /**
111
     * @var string[] map where Field name => placeholder override
112
     */
113
    protected $placeholders = [];
114
115
    /**
116
     * @var bool[] map where Field name => required flag
117
     */
118
    protected $required = [];
119
120
    /**
121
     * @var string[] list of void elements
122
     *
123
     * @see tag()
124
     *
125
     * @link http://www.w3.org/TR/html-markup/syntax.html#void-elements
126
     */
127
    private static $void_elements = [
128
        'area'    => true,
129
        'base'    => true,
130
        'br'      => true,
131
        'col'     => true,
132
        'command' => true,
133
        'embed'   => true,
134
        'hr'      => true,
135
        'img'     => true,
136
        'input'   => true,
137
        'keygen'  => true,
138
        'link'    => true,
139
        'meta'    => true,
140
        'param'   => true,
141
        'source'  => true,
142
        'track'   => true,
143
        'wbr'     => true,
144
    ];
145
146
    /**
147
     * @param InputModel|array|null $model           input model, or (possibly nested) input array (e.g. $_GET or $_POST)
148
     * @param string|string[]|null  $collection_name collection name(s) for inputs, e.g. 'myform' or ['myform', '123'] etc.
149
     * @param string|null           $id_prefix       base id for inputs, e.g. 'myform' or 'myform-123', etc.
150
     */
151 23
    public function __construct($model = null, $collection_name = null, $id_prefix = null)
152
    {
153 23
        $this->model = InputModel::create($model);
154 23
        $this->collection_name = $collection_name;
155 23
        $this->id_prefix = $id_prefix === null
156 22
            ? ($collection_name === null
157 20
                ? null
158 22
                : implode('-', (array) $this->collection_name))
159 1
            : $id_prefix;
160 23
    }
161
162
    /**
163
     * @param FieldInterface $field
164
     *
165
     * @return string
166
     *
167
     * @see Field::getLabel()
168
     */
169 3
    public function getLabel(FieldInterface $field)
170
    {
171 3
        return array_key_exists($field->getName(), $this->labels)
172 1
            ? $this->labels[$field->getName()]
173 3
            : $field->getLabel();
174
    }
175
176
    /**
177
     * Override the label defined by the Field
178
     *
179
     * @param FieldInterface $field
180
     * @param string         $label
181
     */
182 1
    public function setLabel(FieldInterface $field, $label)
183
    {
184 1
        $this->labels[$field->getName()] = $label;
185 1
    }
186
187
    /**
188
     * @param FieldInterface $field
189
     *
190
     * @return string
191
     *
192
     * @see Field::getPlaceholder()
193
     */
194 10
    public function getPlaceholder(FieldInterface $field)
195
    {
196 10
        return array_key_exists($field->getName(), $this->placeholders)
197 1
            ? $this->placeholders[$field->getName()]
198 10
            : $field->getPlaceholder();
199
    }
200
201
    /**
202
     * Override the placeholder label defined by the Field
203
     *
204
     * @param FieldInterface $field
205
     * @param string         $placeholder
206
     */
207 1
    public function setPlaceholder(FieldInterface $field, $placeholder)
208
    {
209 1
        $this->placeholders[$field->getName()] = $placeholder;
210 1
    }
211
212
    /**
213
     * @param FieldInterface $field
214
     *
215
     * @return string|null computed name-attribute
216
     */
217 16
    public function getName(FieldInterface $field)
218
    {
219 16
        $names = (array) $this->collection_name;
220 16
        $names[] = $field->getName();
221
222 16
        return $names[0] . (count($names) > 1 ? '[' . implode('][', array_slice($names, 1)) . ']' : '');
223
    }
224
225
    /**
226
     * @param FieldInterface $field
227
     *
228
     * @return string|null computed id-attribute
229
     */
230 13
    public function getId(FieldInterface $field)
231
    {
232 13
        return $this->id_prefix
233 3
            ? $this->id_prefix . '-' . $field->getName()
234 13
            : null;
235
    }
236
237
    /**
238
     * Conditionally create (or add) CSS class-names for Field status, e.g.
239
     * {@see $required_class} for {@see Field::$required} and {@see $error_class}
240
     * if the {@see $model} contains an error.
241
     *
242
     * @param FieldInterface $field
243
     *
244
     * @return array map of HTML attributes (with additonial classes)
245
     *
246
     * @see $required_class
247
     * @see $error_class
248
     */
249 4
    public function getAttrs(FieldInterface $field)
250
    {
251 4
        $classes = [];
252
253 4
        if ($this->required_class !== null && $this->isRequired($field)) {
254 3
            $classes[] = $this->required_class;
255
        }
256
257 4
        if ($this->error_class !== null && $this->model->hasError($field)) {
258 2
            $classes[] = $this->error_class;
259
        }
260
261 4
        return ['class' => $classes];
262
    }
263
264
    /**
265
     * @param FieldInterface $field
266
     *
267
     * @return bool true, if the given Field is required
268
     */
269 4
    public function isRequired(FieldInterface $field)
270
    {
271 4
        return array_key_exists($field->getName(), $this->required)
272 1
            ? $this->required[$field->getName()]
273 4
            : $field->isRequired();
274
    }
275
276
    /**
277
     * Override the required flag defined by the Field
278
     *
279
     * @param FieldInterface $field
280
     * @param bool           $required
281
     */
282 1
    public function setRequired(FieldInterface $field, $required = true)
283
    {
284 1
        $this->required[$field->getName()] = (bool) $required;
285 1
    }
286
287
    /**
288
     * Build an HTML input for a given Field.
289
     *
290
     * @param FieldInterface $field
291
     * @param array          $attr
292
     *
293
     * @return string
294
     *
295
     * @throws RuntimeException if the given Field cannot be rendered
296
     */
297 16
    public function render(FieldInterface $field, array $attr = [])
298
    {
299 16
        return $field->renderInput($this, $this->model, $attr);
300
    }
301
302
    /**
303
     * Builds an HTML group containing a label and rendered input for a given Field.
304
     *
305
     * @param FieldInterface $field
306
     * @param string|null    $label      label text (optional)
307
     * @param array          $input_attr map of HTML attributes for the input (optional)
308
     * @param array          $group_attr map of HTML attributes for the group (optional)
309
     *
310
     * @return string
311
     */
312 1
    public function renderGroup(FieldInterface $field, $label = null, array $input_attr = [], $group_attr = [])
313
    {
314
        return
315 1
            $this->groupFor($field, $group_attr)
316 1
            . $this->labelFor($field, $label)
317 1
            . $this->render($field, $input_attr)
318 1
            . $this->endGroup();
319
    }
320
321
    /**
322
     * Builds an HTML div with state-classes, containing a rendered input for a given Field.
323
     *
324
     * @param FieldInterface $field
325
     * @param array          $input_attr attributes for the generated input
326
     * @param array          $div_attr   attributes for the wrapper div
327
     *
328
     * @return string HTML
329
     */
330 2
    public function renderDiv(FieldInterface $field, array $input_attr = [], $div_attr = [])
331
    {
332 2
        return $this->divFor($field, $this->render($field, $input_attr), $div_attr);
333
    }
334
335
    /**
336
     * Visit a given Field - temporarily swaps out {@see $model}, {@see $name_prefix}
337
     * and {@see $id_prefix} and merges any changes made to the model while calling
338
     * the given function.
339
     *
340
     * @param FieldInterface|int|string $field Field instance, or an integer index, or string key
341
     * @param callable                  $func  function (InputModel $model): mixed
342
     *
343
     * @return mixed
344
     */
345 2
    public function visit($field, $func)
346
    {
347 2
        $model = $this->model;
348 2
        $name_prefix = $this->collection_name;
349 2
        $id_prefix = $this->id_prefix;
350
351 2
        $key = $field instanceof FieldInterface
352 2
            ? $field->getName()
353 2
            : (string) $field;
354
355 2
        $this->model = InputModel::create(@$model->input[$key], $model->getError($key));
0 ignored issues
show
Bug introduced by
It seems like $model->getError($key) targeting mindplay\kissform\InputModel::getError() can also be of type string; however, mindplay\kissform\InputModel::create() does only seem to accept array<integer,string>|null, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
356 2
        $this->collection_name = array_merge((array) $this->collection_name, [$key]);
0 ignored issues
show
Documentation Bug introduced by
It seems like array_merge((array) $thi...tion_name, array($key)) of type array is incompatible with the declared type string|array<integer,string>|null of property $collection_name.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
357 2
        $this->id_prefix = $this->id_prefix
358 1
            ? $this->id_prefix . '-' . $key
359 1
            : null;
360
361 2
        call_user_func($func, $this->model);
362
363 2
        if ($this->model->input !== []) {
364 2
            $model->input[$key] = $this->model->input;
365
        } else {
366 2
            unset($model->input[$key]);
367
        }
368
369 2
        if ($this->model->hasErrors()) {
370 1
            $model->setError($key, $this->model->getErrors());
371
        }
372
373 2
        $this->model = $model;
374 2
        $this->collection_name = $name_prefix;
375 2
        $this->id_prefix = $id_prefix;
376 2
    }
377
378
    /**
379
     * Merge any number of attribute maps, with the latter overwriting the first, and
380
     * with special handling for the class-attribute.
381
     *
382
     * @param array ...$attr
383
     *
384
     * @return array
385
     */
386 17
    public function mergeAttrs()
387
    {
388 17
        $maps = func_get_args();
389
390 17
        $result = [];
391
392 17
        foreach ($maps as $map) {
393 17
            if (isset($map['class'])) {
394 16
                if (isset($result['class'])) {
395 7
                    $map['class'] = array_merge((array) $result['class'], (array) $map['class']);
396
                }
397
            }
398
399 17
            $result = array_merge($result, $map);
400
        }
401
402 17
        return $result;
403
    }
404
405
    /**
406
     * Encode plain text as HTML
407
     *
408
     * @param string $text  plain text
409
     * @param int    $flags encoding flags (optional, see htmlspecialchars)
410
     *
411
     * @return string escaped HTML
412
     *
413
     * @see softEscape()
414
     */
415 21
    public function escape($text, $flags = ENT_COMPAT)
416
    {
417 21
        return htmlspecialchars($text, $flags, $this->encoding, true);
418
    }
419
420
    /**
421
     * Encode plain text as HTML, while attempting to avoid double-encoding
422
     *
423
     * @param string $text  plain text
424
     * @param int    $flags encoding flags (optional, see htmlspecialchars)
425
     *
426
     * @return string escaped HTML
427
     *
428
     * @see escape()
429
     */
430 4
    public function softEscape($text, $flags = ENT_COMPAT)
431
    {
432 4
        return htmlspecialchars($text, $flags, $this->encoding, false);
433
    }
434
435
    /**
436
     * Build an opening and closing HTML tag (or a self-closing tag) - examples:
437
     *
438
     *     echo $renderer->tag('input', array('type' => 'text'));  => <input type="text"/>
439
     *
440
     *     echo $renderer->tag('div', array(), 'Foo &amp; Bar');   => <div>Foo &amp; Bar</div>
441
     *
442
     *     echo $renderer->tag('script', array(), '');             => <script></script>
443
     *
444
     * @param string      $name HTML tag name
445
     * @param array       $attr map of HTML attributes
446
     * @param string|null $html inner HTML, or NULL to build a self-closing tag
447
     *
448
     * @return string
449
     *
450
     * @see openTag()
451
     */
452 20
    public function tag($name, array $attr = [], $html = null)
453
    {
454 20
        return $html === null && isset(self::$void_elements[$name])
455 15
            ? '<' . $name . $this->attrs($attr) . '/>'
456 20
            : '<' . $name . $this->attrs($attr) . '>' . $html . '</' . $name . '>';
457
    }
458
459
    /**
460
     * Build an open HTML tag; remember to close the tag.
461
     *
462
     * Note that there is no closeTag() equivalent, as this wouldn't help with anything
463
     * and would actually require more code than e.g. a simple literal `</div>`
464
     *
465
     * @param string $name HTML tag name
466
     * @param array  $attr map of HTML attributes
467
     *
468
     * @return string
469
     *
470
     * @see tag()
471
     */
472 3
    public function openTag($name, array $attr = [])
473
    {
474 3
        return '<' . $name . $this->attrs($attr) . '>';
475
    }
476
477
    /**
478
     * Build HTML attributes for use inside an HTML (or XML) tag.
479
     *
480
     * Includes a leading space, since this is usually used inside a tag, e.g.:
481
     *
482
     *     <div<?= $form->attrs(array('class' => 'foo')) ?>>...</div>
483
     *
484
     * Accepts strings, or arrays of strings, as attribute-values - arrays will
485
     * be folded using space as a separator, e.g. useful for the class-attribute.
486
     *
487
     * Attributes containing NULL, FALSE or an empty array() are ignored.
488
     *
489
     * Attributes containing TRUE are rendered as value-less attributes.
490
     *
491
     * @param array $attr map where attribute-name => attribute value(s)
492
     * @param bool  $sort true, to sort attributes by name; otherwise false (sorting is enabled by default)
493
     *
494
     * @return string
495
     */
496 21
    public function attrs(array $attr, $sort = true)
497
    {
498 21
        if ($sort) {
499 21
            ksort($attr);
500
        }
501
502 21
        $html = '';
503
504 21
        foreach ($attr as $name => $value) {
505 21
            if (is_array($value)) {
506 8
                $value = count($value)
507 7
                    ? implode(' ', $value) // fold multi-value attribute (e.g. class-names)
508 8
                    : null; // filter empty array
509
            }
510
511 21
            if ($value === null || $value === false) {
512 15
                continue; // skip NULL and FALSE attributes
513
            }
514
515 21
            if ($value === true) {
516 6
                $html .= $this->xhtml ?
517 1
                    ' ' . $name . '="' . $name . '"' // e.g. disabled="disabled" (as required for XHTML)
518 6
                    : ' ' . $name; // value-less HTML attribute
519
            } else {
520 21
                $html .= ' ' . $name . '="' . $this->escape($value) . '"';
521
            }
522
        }
523
524 21
        return $html;
525
    }
526
527
    /**
528
     * Builds an HTML <input> tag
529
     *
530
     * @param string $type
531
     * @param string $name
0 ignored issues
show
Documentation introduced by
Should the type for parameter $name not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
532
     * @param string $value
0 ignored issues
show
Documentation introduced by
Should the type for parameter $value not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
533
     * @param array  $attr map of HTML attributes
534
     *
535
     * @return string
536
     *
537
     * @see inputFor()
538
     */
539 1
    public function input($type, $name = null, $value= null, $attr = [])
540
    {
541 1
        return $this->tag(
542 1
            'input',
543 1
            $this->mergeAttrs(
544
                [
545 1
                    'type'  => $type,
546 1
                    'name'  => $name,
547 1
                    'value' => $value,
548
                ],
549
                $attr
550
            )
551
        );
552
    }
553
554
    /**
555
     * Build an HTML input-tag for a given Field
556
     *
557
     * @param FieldInterface $field
558
     * @param string         $type HTML input type-attribute (e.g. "text", "password", etc.)
559
     * @param array          $attr map of HTML attributes
560
     *
561
     * @return string
562
     */
563 9
    public function inputFor(FieldInterface $field, $type, array $attr = [])
564
    {
565 9
        return $this->tag(
566 9
            'input',
567 9
            $this->mergeAttrs(
568
                [
569 9
                    'name'        => $this->getName($field),
570 9
                    'id'          => $this->getId($field),
571 9
                    'class'       => $this->input_class,
572 9
                    'value'       => $this->model->getInput($field),
573 9
                    'type'        => $type,
574 9
                    'placeholder' => @$attr['placeholder'] ?: $this->getPlaceholder($field),
575
                ],
576
                $attr
577
            )
578
        );
579
    }
580
581
    /**
582
     * Build an HTML opening tag for an input group
583
     *
584
     * Call {@see endGroup()} to create the matching closing tag.
585
     *
586
     * @param array $attr optional map of HTML attributes
587
     *
588
     * @return string
589
     *
590
     * @see groupFor()
591
     */
592 1
    public function group($attr = [])
593
    {
594 1
        return $this->openTag(
595 1
            $this->group_tag,
596 1
            $this->mergeAttrs($this->group_attrs, $attr)
597
        );
598
    }
599
600
    /**
601
     * Build an HTML opening tag for an input group, with CSS classes added for
602
     * {@see Field::$required} and error state, as needed.
603
     *
604
     * Call {@see endGroup()} to create the matching closing tag.
605
     *
606
     * @param FieldInterface $field
607
     * @param array          $attr map of HTML attributes (optional)
608
     *
609
     * @return string
610
     *
611
     * @see $group_tag
612
     * @see $group_attrs
613
     * @see $required_class
614
     * @see $error_class
615
     * @see endGroup()
616
     */
617 2
    public function groupFor(FieldInterface $field, array $attr = [])
618
    {
619 2
        return $this->openTag(
620 2
            $this->group_tag,
621 2
            $this->mergeAttrs($this->group_attrs, $this->getAttrs($field), $attr)
622
        );
623
    }
624
625
    /**
626
     * Returns the matching closing tag for a {@see group()} or {@see groupFor()} tag.
627
     *
628
     * @return string
629
     *
630
     * @see groupFor()
631
     * @see $group_tag
632
     */
633 2
    public function endGroup()
634
    {
635 2
        return "</{$this->group_tag}>";
636
    }
637
638
    /**
639
     * Builds an HTML div with state-classes, containing the given HTML.
640
     *
641
     * @param FieldInterface $field
642
     * @param string         $html inner HTML for the generated div
643
     * @param array          $attr additional attributes for the div
644
     *
645
     * @return string HTML
646
     */
647 2
    public function divFor(FieldInterface $field, $html, array $attr = [])
648
    {
649 2
        return $this->tag('div', $this->mergeAttrs($this->getAttrs($field), $attr), $html);
650
    }
651
652
    /**
653
     * Build a `<label for="id" />` tag
654
     *
655
     * @param string $for   target element ID
656
     * @param string $label label text
657
     * @param array  $attr  map of HTML attributes
658
     *
659
     * @return string
660
     *
661
     * @see labelFor()
662
     */
663 3
    public function label($for, $label, $attr = [])
664
    {
665 3
        return $this->tag(
666 3
            'label',
667 3
            $this->mergeAttrs(
668
                [
669 3
                    'for' => $for,
670 3
                    'class' => $this->label_class
671
                ],
672
                $attr
673
            ),
674 3
            $this->softEscape($label . $this->label_suffix)
675
        );
676
    }
677
678
    /**
679
     * Build an HTML `<label for="id" />` tag
680
     *
681
     * @param FieldInterface $field
682
     * @param string|null    $label label text (optional)
683
     * @param array          $attr  map of HTML attributes
684
     *
685
     * @return string
686
     *
687
     * @see Field::getLabel()
688
     *
689
     * @throws RuntimeException if a label cannot be produced
690
     */
691 2
    public function labelFor(FieldInterface $field, $label = null, array $attr = [])
0 ignored issues
show
Unused Code introduced by
The parameter $attr is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
692
    {
693 2
        $id = $this->getId($field);
694
695 2
        if ($id === null) {
696 1
            throw new RuntimeException("cannot produce a label when FormHelper::\$id_prefix is NULL");
697
        }
698
699 2
        if ($label === null) {
700 2
            $label = $this->getLabel($field);
701
702 2
            if ($label === null) {
703 1
                throw new RuntimeException("the given Field has no defined label");
704
            }
705
        }
706
707 2
        return $this->label($id, $label);
708
    }
709
}
710