Menu   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 543
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 1 Features 1
Metric Value
wmc 57
eloc 182
c 1
b 1
f 1
dl 0
loc 543
ccs 185
cts 185
cp 1
rs 5.04

24 Methods

Rating   Name   Duplication   Size   Complexity  
A attributes() 0 5 1
A brand() 0 5 1
B renderItem() 0 53 6
A id() 0 5 1
A labelTemplate() 0 5 1
A render() 0 9 2
A activeCssClass() 0 5 1
A activateParents() 0 5 1
C renderItems() 0 67 14
A subMenuTemplate() 0 5 1
A urlTemplate() 0 5 1
A items() 0 5 1
A autoIdPrefix() 0 5 1
A itemsTag() 0 9 2
A currentPath() 0 5 1
A deactivateItems() 0 5 1
A renderMenu() 0 27 3
A firstItemCssClass() 0 5 1
A itemAttributes() 0 5 1
A hiddenEmptyItems() 0 5 1
A isItemActive() 0 3 3
A renderIcon() 0 11 2
B normalizeItems() 0 50 9
A lastItemCssClass() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like Menu often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Menu, and based on these observations, apply Extract Interface, too.

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