Passed
Pull Request — master (#60)
by Wilmer
12:52
created

Menu::normalizeItems()   B

Complexity

Conditions 9
Paths 14

Size

Total Lines 50
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 9

Importance

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