Test Setup Failed
Pull Request — master (#61)
by Wilmer
02:18
created

Menu::renderItem()   B

Complexity

Conditions 6
Paths 11

Size

Total Lines 50
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 1
Bugs 1 Features 1
Metric Value
cc 6
eloc 23
nc 11
nop 2
dl 0
loc 50
ccs 0
cts 0
cp 0
crap 42
rs 8.9297
c 1
b 1
f 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Bulma;
6
7
use InvalidArgumentException;
8
use JsonException;
9
use Yiisoft\Html\Html;
10
use Yiisoft\Html\Tag\CustomTag;
11
use Yiisoft\Html\Tag\I;
12
use Yiisoft\Html\Tag\P;
13
use Yiisoft\Html\Tag\Span;
14
use Yiisoft\Widget\Widget;
15
16
use function array_merge;
17
use function count;
18
use function implode;
19
use function strtr;
20
21
/**
22
 * The Bulma menu is a vertical navigation component.
23
 *
24
 * @link https://bulma.io/documentation/components/menu/
25
 */
26
final class Menu extends Widget
27
{
28
    private string $autoIdPrefix = 'w';
29
    private array $attributes = [];
30
    private string $activeCssClass = 'is-active';
31
    private bool $activateItems = true;
32
    private bool $activateParents = false;
33
    private string $brand = '';
34
    private string $currentPath = '';
35
    private string $firstItemCssClass = '';
36
    private bool $hiddenEmptyItems = false;
37
    private string $menuClass = 'menu';
38
    private string $menuListClass = 'menu-list';
39
    private array $items = [];
40
    private array $itemAttributes = [];
41
    private array $itemsAttributes = [];
42
    /** @psalm-var null|non-empty-string $itemsTag */
43
    private ?string $itemsTag = 'li';
44
    private string $lastItemCssClass = '';
45
    private string $urlTemplate = '<a href={url}>{icon}{label}</a>';
46
    private string $labelTemplate = '{label}';
47
    private string $subMenuTemplate = "<ul class = menu-list>\n{items}\n</ul>";
48
49 2
    /**
50
     * Returns a new instance with the activated parent items.
51 2
     *
52 2
     * Activates parent menu items when one of the corresponding child menu items is active.
53 2
     * The activated parent menu items will also have its CSS classes appended with {@see activeCssClass()}.
54
     *
55
     * @return self
56
     */
57
    public function activateParents(): self
58
    {
59
        $new = clone $this;
60
        $new->activateParents = true;
61
        return $new;
62
    }
63
64 2
    /**
65
     * Returns a new instance with the specified active CSS class.
66 2
     *
67 2
     * @param string $value The CSS class to be appended to the active menu item.
68 2
     *
69
     * @return self
70
     */
71
    public function activeCssClass(string $value): self
72
    {
73
        $new = clone $this;
74
        $new->activeCssClass = $value;
75
        return $new;
76
    }
77
78 3
    /**
79
     * Returns a new instance with the specified prefix to the automatically generated widget IDs.
80 3
     *
81 3
     * @param string $value The prefix to the automatically generated widget IDs.
82 3
     *
83
     * @return self
84
     */
85
    public function autoIdPrefix(string $value): self
86
    {
87
        $new = clone $this;
88
        $new->autoIdPrefix = $value;
89
        return $new;
90
    }
91
92 2
    /**
93
     * The HTML attributes. The following special attributes are recognized.
94 2
     *
95 2
     * @param array $values Attribute values indexed by attribute names.
96 2
     *
97
     * @return self
98
     *
99
     * {@see \Yiisoft\Html\Html::renderTagAttributes()} For details on how attributes are being rendered.
100
     */
101
    public function attributes(array $values): self
102
    {
103
        $new = clone $this;
104
        $new->attributes = $values;
105
        return $new;
106 8
    }
107
108 8
    /**
109 8
     * Returns a new instance with the specified HTML code of brand.
110 8
     *
111
     * @param string $value The HTML code of brand.
112
     *
113
     * @return self
114
     */
115
    public function brand(string $value): self
116
    {
117
        $new = clone $this;
118 2
        $new->brand = $value;
119
        return $new;
120 2
    }
121 2
122 2
    /**
123
     * Returns a new instance with the specified current path.
124
     *
125
     * @param string $value The current path.
126
     *
127
     * @return self
128
     */
129
    public function currentPath(string $value): self
130
    {
131
        $new = clone $this;
132 2
        $new->currentPath = $value;
133
        return $new;
134 2
    }
135 2
136 2
    /**
137
     * Disables active items according to their current path and returns a new instance.
138
     *
139
     * @return self
140
     *
141
     * {@see isItemActive}
142
     */
143
    public function deactivateItems(): self
144
    {
145
        $new = clone $this;
146
        $new->activateItems = false;
147 2
        return $new;
148
    }
149 2
150 2
    /**
151 2
     * Returns a new instance with the specified first item CSS class.
152
     *
153
     * @param string $value The CSS class that will be assigned to the first item in the main menu or each submenu.
154
     *
155
     * @return self
156
     */
157
    public function firstItemCssClass(string $value): self
158
    {
159
        $new = clone $this;
160
        $new->firstItemCssClass = $value;
161
        return $new;
162
    }
163
164
    /**
165
     * Returns a new instance with the specified hidden empty items.
166
     *
167
     * @return self
168
     */
169
    public function hiddenEmptyItems(): self
170
    {
171
        $new = clone $this;
172
        $new->hiddenEmptyItems = true;
173
        return $new;
174
    }
175
176
    /**
177
     * Returns a new instance with the specified ID of the widget.
178
     *
179
     * @param string $value The ID of the widget.
180
     *
181
     * @return self
182
     */
183
    public function id(string $value): self
184
    {
185 20
        $new = clone $this;
186
        $new->attributes['id'] = $value;
187 20
        return $new;
188 20
    }
189 20
190
    /**
191
     * Returns a new instance with the specified item attributes.
192
     *
193
     * @param array $value List of HTML attributes shared by all menu {@see items}. If any individual menu item
194
     * specifies its  `attributes`, it will be merged with this property before being used to generate the HTML
195
     * attributes for the menu item tag. The following special attributes are recognized:
196
     *
197
     * @return self
198
     *
199
     * {@see Html::renderTagAttributes() For details on how attributes are being rendered}
200
     */
201
    public function itemAttributes(array $value): self
202
    {
203
        $new = clone $this;
204
        $new->itemAttributes = $value;
205
        return $new;
206 2
    }
207
208 2
    /**
209 2
     * Returns a new instance with the specified items.
210 2
     *
211
     * @param array $value List of menu items. Each menu item should be an array of the following structure:
212
     *
213
     * - label: string, optional, specifies the menu item label. When {@see encode} is true, the label will be
214
     *   HTML-encoded. If the label is not specified, an empty string will be used.
215
     * - encode: bool, optional, whether this item`s label should be HTML-encoded. This param will override global
216
     *   {@see encode} param.
217
     * - url: string or array, optional, specifies the URL of the menu item. When this is set, the actual menu item
218
     *   content will be generated using {@see urlTemplate}; otherwise, {@see labelTemplate} will be used.
219
     * - visible: bool, optional, whether this menu item is visible. Defaults to true.
220
     * - items: array, optional, specifies the sub-menu items. Its format is the same as the parent items.
221
     * - active: bool or Closure, optional, whether this menu item is in active state (currently selected). When
222
     *   using a closure, its signature should be `function ($item, $hasActiveChild, $isItemActive, $Widget)`. Closure
223
     *   must return `true` if item should be marked as `active`, otherwise - `false`. If a menu item is active, its CSS
224 3
     *   class will be appended with {@see activeCssClass}. If this option is not set, the menu item will be set active
225
     *   automatically when the current request is triggered by `url`. For more details, please refer to
226 3
     *   {@see isItemActive()}.
227 3
     * - labelTemplate: string, optional, the template used to render the content of this menu item. The token `{label}`
228 3
     *   will be replaced by the label of the menu item. If this option is not set, {@see labelTemplate} will be used
229
     *   instead.
230
     * - urlTemplate: string, optional, the template used to render the content of this menu item. The token `{url}`
231
     *   will be replaced by the URL associated with this menu item. If this option is not set, {@see urlTemplate} will
232
     *   be used instead.
233
     * - subMenuTemplate: string, optional, the template used to render the list of sub-menus. The token `{items}` will
234
     *   be replaced with the rendered sub-menu items. If this option is not set, {@see subMenuTemplate} will be used
235
     *   instead.
236
     * - itemAttributes: array, optional, the HTML attributes for the item container tag.
237
     * - icon: string, optional, class icon.
238 6
     * - iconAttributes: array, optional, the HTML attributes for the container icon.
239
     *
240 6
     * @return self
241 6
     */
242 6
    public function items(array $value): self
243
    {
244
        $new = clone $this;
245
        $new->items = $value;
246
        return $new;
247
    }
248
249
    /**
250
     * Return a new instance with tag for item container.
251
     *
252
     * @param string|null $value The tag for item container, `null` value means that container tag will not be rendered.
253
     *
254
     * @return self
255 3
     */
256
    public function itemsTag(?string $value): self
257 3
    {
258 3
        if ($value === '') {
259 3
            throw new InvalidArgumentException('Tag for item container cannot be empty.');
260
        }
261
262
        $new = clone $this;
263
        $new->itemsTag = $value;
264
        return $new;
265
    }
266
267
    /**
268
     * Returns a new instance with the specified label template.
269
     *
270
     * @param string $value The template used to render the body of a menu which is NOT a link.
271
     *
272
     * In this template, the token `{label}` will be replaced with the label of the menu item.
273
     *
274
     * This property will be overridden by the `template` option set in individual menu items via {@see items}.
275 2
     *
276
     * @return self
277 2
     */
278 2
    public function labelTemplate(string $value): self
279 2
    {
280
        $new = clone $this;
281
        $new->labelTemplate = $value;
282
        return $new;
283
    }
284
285
    /**
286
     * Returns a new instance with the specified last item CSS class.
287
     *
288
     * @param string $value The CSS class that will be assigned to the last item in the main menu or each submenu.
289
     *
290
     * @return self
291 2
     */
292
    public function lastItemCssClass(string $value): self
293 2
    {
294 2
        $new = clone $this;
295 2
        $new->lastItemCssClass = $value;
296
        return $new;
297
    }
298
299
    /**
300
     * The template used to render a list of sub-menus.
301
     *
302
     * In this template, the token `{items}` will be replaced with the rendered sub-menu items.
303 19
     *
304
     * @param string $value
305 19
     *
306
     * @return self
307 19
     */
308 1
    public function subMenuTemplate(string $value): self
309
    {
310
        $new = clone $this;
311 18
        $new->subMenuTemplate = $value;
312
        return $new;
313 18
    }
314
315
    /**
316 18
     * Returns a new instance with the specified link template.
317
     *
318 18
     * @param string $value The template used to render the body of a menu which is a link. In this template, the token
319 18
     * `{url}` will be replaced with the corresponding link URL; while `{label}` will be replaced with the link text.
320
     *
321 18
     * This property will be overridden by the `template` option set in individual menu items via {@see items}.
322 18
     *
323 18
     * @return self
324 18
     */
325 18
    public function urlTemplate(string $value): self
326
    {
327
        $new = clone $this;
328 18
        $new->urlTemplate = $value;
329 10
        return $new;
330
    }
331
332 18
    /**
333 1
     * Renders the menu.
334
     *
335
     * @throws JsonException
336 18
     *
337 5
     * @return string the result of Widget execution to be outputted.
338
     */
339
    protected function run(): string
340 18
    {
341
        $items = $this->normalizeItems($this->items);
342 18
343
        if (empty($items)) {
344 18
            return '';
345 9
        }
346 9
347 9
        return $this->renderMenu($items);
348
    }
349
350
    /**
351 18
     * Check to see if a child item is active optionally activating the parent.
352 11
     *
353 11
     * @param array $items {@see items}
354
     * @param bool $active Should the parent be active too.
355 11
     *
356
     * @return array
357
     *
358 18
     * {@see items}
359 1
     */
360 18
    private function normalizeItems(array $items, bool &$active = false): array
361
    {
362
        /**
363
         * @psalm-var array<
364 18
         *  string,
365
         *  array{
366
         *    active?: bool,
367 18
         *    attributes?: array,
368
         *    encode?: bool,
369 18
         *    icon?: string,
370 18
         *    iconAttributes?: array,
371
         *    items?: array,
372 18
         *    label: string,
373
         *    labelTemplate?: string,
374 18
         *    urlTemplate?: string,
375 2
         *    subMenuTemplate?: string,
376
         *    url: string,
377
         *    visible?: bool
378 18
         * }> $items
379 11
         */
380
        foreach ($items as $i => $child) {
381 17
            if (isset($child['items']) && $child['items'] === [] && $this->hiddenEmptyItems) {
382
                unset($items[$i]);
383 18
                continue;
384 18
            }
385 18
386 18
            $url = $child['url'] ?? '#';
387
            $active = $child['active'] ?? false;
388
389
            if ($active === false) {
390 11
                $child['active'] = $this->isItemActive($url, $this->currentPath, $this->activateItems);
391
            }
392 11
393 11
            if ($this->activateParents) {
394
                $active = true;
395
            }
396
397 19
            $childItems = $child['items'] ?? [];
398
399 19
            if ($childItems !== []) {
400 18
                $items[$i]['items'] = $this->normalizeItems($childItems);
401 1
402
                if ($active) {
403 18
                    $items[$i]['active'] = true;
404 18
                    $active = true;
405 18
                }
406 18
            }
407
        }
408 18
409 9
        return $items;
410 9
    }
411 1
412 1
    /**
413 1
     * Checks whether a menu item is active.
414 1
     *
415
     * This is done by checking if {@see currentPath} match that specified in the `url` option of the menu item. When
416
     * the `url` option of a menu item is specified in terms of an array, its first element is treated as the
417
     * currentPath for the item and the rest of the elements are the associated parameters. Only when its currentPath
418
     * and parameters match {@see currentPath}, respectively, will a menu item be considered active.
419 18
     *
420 17
     * @param string $url The menu item's URL.
421 7
     * @param string $currentPath The currentPath.
422
     * @param bool $activateItems Whether to activate the parent menu items when the currentPath matches.
423 17
     *
424
     * @return bool whether the menu item is active
425 3
     */
426 1
    private function isItemActive(string $url, string $currentPath, bool $activateItems): bool
427 3
    {
428 2
        return ($currentPath !== '/') && ($url === $currentPath) && $activateItems;
429
    }
430
431
    /**
432
     * @throws JsonException
433 19
     */
434
    private function renderItems(array $items): string
435
    {
436 18
        $lines = [];
437
        $n = count($items);
438 18
439
        /** @psalm-var array<array-key, array> $items */
440
        foreach ($items as $i => $item) {
441 2
            /** @var array */
442
            $subItems = $item['items'] ?? [];
443 2
444
            /** @var string */
445 2
            $url = $item['url'] ?? '';
446 2
447 2
            /** @var array */
448 2
            $attributes = $item['itemAttributes'] ?? [];
449
450
            /** @var array */
451 2
            $linkAttributes = $item['linkAttributes'] ?? [];
452
            $attributes = array_merge($this->itemAttributes, $attributes);
453
454 18
            if ($i === 0 && $this->firstItemCssClass !== '') {
455
                Html::addCssClass($attributes, $this->firstItemCssClass);
456 18
            }
457 18
458 18
            if ($i === $n - 1 && $this->lastItemCssClass !== '') {
459
                Html::addCssClass($attributes, $this->lastItemCssClass);
460 18
            }
461
462 18
            if (array_key_exists('tag', $item)) {
463 18
                /** @psalm-var null|non-empty-string */
464
                $tag = $item['tag'];
465 18
            } else {
466 1
                $tag = $this->itemsTag;
467
            }
468
469 18
            /** @var bool */
470 17
            $active = $item['active'] ?? $this->isItemActive($url, $this->currentPath, $this->activateItems);
471
472 18
            if ($active) {
473 18
                Html::addCssClass($linkAttributes, $this->activeCssClass);
474 17
            }
475
476 18
            $menu = $this->renderItem($item, $linkAttributes);
477
478 18
            if ($subItems !== []) {
479
                /** @var string */
480
                $subMenuTemplate = $item['subMenuTemplate'] ?? $this->subMenuTemplate;
481
                $menu .= strtr($subMenuTemplate, ['{items}' => $this->renderItems($subItems)]);
482
            }
483
484
            if (isset($item['label']) && !isset($item['url'])) {
485
                if (!empty($menu)) {
486
                    $lines[] = $menu;
487
                } else {
488
                    /** @var string */
489
                    $lines[] = $item['label'];
490
                }
491
            } elseif (!empty($menu)) {
492
                $lines[] = $tag === null
493
                ? $menu
494
                : Html::tag($tag, $menu, $attributes)->encode(false)->render();
495
            }
496
        }
497
498
        return implode("\n", $lines);
499
    }
500
501
    /**
502
     * @throws JsonException
503
     */
504
    private function renderItem(array $item, array $linkAttributes): string
505
    {
506
        /** @var bool */
507
        $visible = $item['visible'] ?? true;
508
509
        if ($visible === false) {
510
            return '';
511
        }
512
513
        /** @var bool */
514
        $encode = $item['encode'] ?? true;
515
516
        /** @var string */
517
        $label = $item['label'] ?? '';
518
519
        if ($encode) {
520
            $label = Html::encode($label);
521
        }
522
523
        /** @var string */
524
        $labelTemplate = $item['labelTemplate'] ?? $this->labelTemplate;
525
526
        if (isset($item['url'])) {
527
            /** @var string */
528
            $urlTemplate = $item['urlTemplate'] ?? $this->urlTemplate;
529
530
            $htmlIcon = '';
531
532
            /** @var string|null */
533
            $icon = $item['icon'] ?? null;
534
535
            /** @var array */
536
            $iconAttributes = $item['iconAttributes'] ?? [];
537
538
            if ($icon !== null) {
539
                $htmlIcon = $this->renderIcon($icon, $iconAttributes);
540
            }
541
542
            if ($linkAttributes !== []) {
543
                $url = '"' . Html::encode($item['url']) . '"' . Html::renderTagAttributes($linkAttributes);
544
            } else {
545
                $url = '"' . Html::encode($item['url']) . '"';
546
            }
547
548
            return strtr($urlTemplate, ['{url}' => $url, '{label}' => $label, '{icon}' => $htmlIcon]);
549
        }
550
551
        return strtr(
552
            $labelTemplate,
553
            ['{label}' => P::tag()->class('menu-label')->content($label)->render() . PHP_EOL]
554
        );
555
    }
556
557
    private function renderIcon(string $icon, array $iconAttributes): string
558
    {
559
        return $icon !== ''
560
            ? Span::tag()
561
                ->attributes($iconAttributes)
562
                ->content(I::tag()->class($icon)->render())
563
                ->encode(false)
564
                ->render()
565
            : '';
566
    }
567
568
    /**
569
     * @throws JsonException
570
     */
571
    private function renderMenu(array $items): string
572
    {
573
        $attributes = $this->attributes;
574
        $content = '';
575
        $customTag = CustomTag::name('aside');
576
        $itemsAttributes = $this->itemsAttributes;
577
578
        if (!array_key_exists('id', $attributes)) {
579
            $customTag = $customTag->id(Html::generateId($this->autoIdPrefix) . '-menu');
580
        }
581
582
        Html::addCssClass($attributes, $this->menuClass);
583
        Html::addCssClass($itemsAttributes, $this->menuListClass);
584
585
        if ($this->brand !== '') {
586
            $content .= PHP_EOL . $this->brand;
587
        }
588
589
        $content .= PHP_EOL . Html::openTag('ul', $itemsAttributes);
590
        $content .= PHP_EOL . $this->renderItems($items) . PHP_EOL;
591
        $content .= Html::closeTag('ul') . PHP_EOL;
592
593
        return $customTag->attributes($attributes)->content($content)->encode(false)->render();
594
    }
595
}
596