Passed
Pull Request — master (#53)
by Wilmer
02:51
created

Menu::normalizeItems()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 46
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 18
c 1
b 0
f 0
nc 5
nop 4
dl 0
loc 46
ccs 0
cts 0
cp 0
crap 30
rs 9.3554
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Widgets;
6
7
use InvalidArgumentException;
8
use Stringable;
9
use Yiisoft\Definitions\Exception\CircularReferenceException;
10
use Yiisoft\Definitions\Exception\InvalidConfigException;
11
use Yiisoft\Definitions\Exception\NotInstantiableException;
12
use Yiisoft\Factory\NotFoundException;
13
use Yiisoft\Html\Html;
14
use Yiisoft\Html\Tag\I;
15
use Yiisoft\Html\Tag\Span;
16
use Yiisoft\Widget\Widget;
17
18
use function array_merge;
19
use function count;
20
use function implode;
21
use function is_string;
22
use function strtr;
23
use function trim;
24
25
/**
26
 * Menu displays a multi-level menu using nested HTML lists.
27
 *
28
 * The {@see Menu::items()} method specifies the possible items in the menu.
29
 * A menu item can contain sub-items which specify the sub-menu under that menu item.
30
 *
31
 * Menu checks the current route and request parameters to toggle certain menu items with active state.
32
 *
33
 * Note that Menu only renders the HTML tags about the menu. It does not do any styling.
34
 * You are responsible to provide CSS styles to make it look like a real menu.
35
 *
36
 * The following example shows how to use Menu:
37
 *
38
 * ```php
39
 * <?= Menu::Widget()
40
 *     ->items([
41
 *         ['label' => 'Login', 'link' => 'site/login', 'visible' => true],
42
 *     ]);
43
 * ?>
44
 * ```
45
 */
46
final class Menu extends Widget
47
{
48
    private array $afterAttributes = [];
49
    private string $afterContent = '';
50
    private string $afterTag = 'span';
51
    private string $activeClass = 'active';
52
    private bool $activateItems = true;
53
    private array $attributes = [];
54
    private array $beforeAttributes = [];
55
    private string $beforeContent = '';
56
    private string $beforeTag = 'span';
57
    private bool $container = true;
58
    private string $currentPath = '';
59
    private string $disabledClass = 'disabled';
60
    private bool $dropdownContainer = true;
61
    private array $dropdownContainerAttributes = [];
62
    private string $dropdownContainerTag = 'li';
63
    private array $dropdownDefinitions = [];
64
    private string $firstItemClass = '';
65
    private array $iconContainerAttributes = [];
66 2
    private array $items = [];
67
    private bool $itemsContainer = true;
68 2
    private array $itemsContainerAttributes = [];
69 2
    private string $itemsTag = 'li';
70 2
    private string $lastItemClass = '';
71
    private array $linkAttributes = [];
72
    private string $linkClass = '';
73
    private string $linkTag = 'a';
74
    private string $tagName = 'ul';
75
    private string $template = '{items}';
76
77
    /**
78
     * Return new instance with specified active or disable activate items.
79
     *
80
     * @param bool $value The value to be assigned to the activateItems property.
81 2
     */
82
    public function activateItems(bool $value): self
83 2
    {
84 2
        $new = clone $this;
85 2
        $new->activateItems = $value;
86
87
        return $new;
88
    }
89
90
    /**
91
     * Returns a new instance with the specified active CSS class.
92
     *
93
     * @param string $value The CSS class to be appended to the active menu item.
94
     */
95 3
    public function activeClass(string $value): self
96
    {
97 3
        $new = clone $this;
98 3
        $new->activeClass = $value;
99 3
100
        return $new;
101
    }
102
103
    /**
104
     * Returns a new instance with the specified after container attributes.
105
     *
106
     * @param array $values Attribute values indexed by attribute names.
107
     */
108
    public function afterAttributes(array $values): self
109 4
    {
110
        $new = clone $this;
111 4
        $new->afterAttributes = $values;
112 4
113 4
        return $new;
114
    }
115
116
    /**
117
     * Returns a new instance with the specified after container class.
118
     *
119
     * @param string $value The class name.
120
     */
121 2
    public function afterClass(string $value): self
122
    {
123 2
        $new = clone $this;
124 2
        Html::addCssClass($new->afterAttributes, $value);
125 2
126
        return $new;
127
    }
128
129
    /**
130
     * Returns a new instance with the specified after content.
131
     *
132
     * @param string|Stringable $content The content.
133
     */
134
    public function afterContent(string|Stringable $content): self
135 2
    {
136
        $new = clone $this;
137 2
        $new->afterContent = (string) $content;
138 2
139 2
        return $new;
140
    }
141
142
    /**
143
     * Returns a new instance with the specified after container tag.
144
     *
145
     * @param string $value The after container tag.
146
     */
147
    public function afterTag(string $value): self
148
    {
149
        $new = clone $this;
150 2
        $new->afterTag = $value;
151
152 2
        return $new;
153 2
    }
154 2
155
    /**
156
     * Returns a new instance with the HTML attributes. The following special options are recognized.
157
     *
158
     * @param array $values Attribute values indexed by attribute names.
159
     */
160
    public function attributes(array $values): self
161
    {
162
        $new = clone $this;
163
        $new->attributes = $values;
164
165
        return $new;
166
    }
167
168
    /**
169
     * Returns a new instance with the specified before container attributes.
170
     *
171
     * @param array $values Attribute values indexed by attribute names.
172
     */
173
    public function beforeAttributes(array $values): self
174
    {
175
        $new = clone $this;
176
        $new->beforeAttributes = $values;
177
178
        return $new;
179
    }
180
181
    /**
182
     * Returns a new instance with the specified before container class.
183
     *
184
     * @param string $value The before container class.
185
     */
186 16
    public function beforeClass(string $value): self
187
    {
188 16
        $new = clone $this;
189 16
        Html::addCssClass($new->beforeAttributes, $value);
190 16
191
        return $new;
192
    }
193
194
    /**
195
     * Returns a new instance with the specified before content.
196
     *
197
     * @param string|Stringable $value The content.
198
     */
199
    public function beforeContent(string|Stringable $value): self
200
    {
201
        $new = clone $this;
202
        $new->beforeContent = (string) $value;
203
204
        return $new;
205
    }
206
207 2
    /**
208
     * Returns a new instance with the specified before container tag.
209 2
     *
210 2
     * @param string $value The before container tag.
211 2
     */
212
    public function beforeTag(string $value): self
213
    {
214
        $new = clone $this;
215
        $new->beforeTag = $value;
216
217
        return $new;
218
    }
219
220
    /**
221
     * Returns a new instance with the specified the class `menu` widget.
222
     *
223
     * @param string $value The class `menu` widget.
224
     */
225 3
    public function class(string $value): self
226
    {
227 3
        $new = clone $this;
228 3
        Html::addCssClass($new->attributes, $value);
229 3
230
        return $new;
231
    }
232
233
    /**
234
     * Returns a new instance with the specified enable or disable the container widget.
235
     *
236
     * @param bool $value The container widget enable or disable, for default is `true`.
237
     */
238
    public function container(bool $value): self
239 2
    {
240
        $new = clone $this;
241 2
        $new->container = $value;
242 2
243 2
        return $new;
244
    }
245
246
    /**
247
     * Returns a new instance with the specified the current path.
248
     *
249
     * @param string $value The current path.
250
     */
251
    public function currentPath(string $value): self
252
    {
253
        $new = clone $this;
254
        $new->currentPath = $value;
255
256 3
        return $new;
257
    }
258 3
259 3
    /**
260 3
     * Returns a new instance with the specified disabled CSS class.
261
     *
262
     * @param string $value The CSS class to be appended to the disabled menu item.
263
     */
264
    public function disabledClass(string $value): self
265
    {
266
        $new = clone $this;
267
        $new->disabledClass = $value;
268
269
        return $new;
270
    }
271
272
    /**
273
     * Returns a new instance with the specified dropdown container class.
274
     *
275
     * @param string $value The dropdown container class.
276 2
     */
277
    public function dropdownContainerClass(string $value): self
278 2
    {
279 2
        $new = clone $this;
280 2
        Html::addCssClass($new->dropdownContainerAttributes, $value);
281
282
        return $new;
283
    }
284
285
    /**
286
     * Returns a new instance with the specified dropdown container tag.
287
     *
288
     * @param string $value The dropdown container tag.
289
     */
290 15
    public function dropdownContainerTag(string $value): self
291
    {
292 15
        $new = clone $this;
293
        $new->dropdownContainerTag = $value;
294 15
295 2
        return $new;
296
    }
297
298 13
    /**
299 13
     * Returns a new instance with the specified dropdown definition widget.
300
     *
301 13
     * @param array $values The dropdown definition widget.
302 1
     */
303 13
    public function dropdownDefinitions(array $values): self
304
    {
305
        $new = clone $this;
306
        $new->dropdownDefinitions = $values;
307
308
        return $new;
309
    }
310
311
    /**
312
     * Returns a new instance with the specified first item CSS class.
313
     *
314
     * @param string $value The CSS class that will be assigned to the first item in the main menu or each submenu.
315
     */
316 13
    public function firstItemClass(string $value): self
317
    {
318 13
        $new = clone $this;
319 13
        $new->firstItemClass = $value;
320
321 13
        return $new;
322 13
    }
323 13
324 13
    /**
325
     * Returns a new instance with the specified icon container attributes.
326 13
     *
327 5
     * @param array $values Attribute values indexed by attribute names.
328
     */
329
    public function iconContainerAttributes(array $values): self
330 13
    {
331 1
        $new = clone $this;
332
        $new->iconContainerAttributes = $values;
333
334 13
        return $new;
335 1
    }
336
337
    /**
338 13
     * List of items in the nav widget. Each array element represents a single menu item which can be either a string or
339
     * an array with the following structure:
340 13
     *
341
     * - label: string, required, the nav item label.
342 13
     * - active: bool, whether the item should be on active state or not.
343 2
     * - disabled: bool, whether the item should be on disabled state or not. For default `disabled` is false.
344 2
     * - encodeLabel: bool, whether the label should be HTML encoded or not. For default `encodeLabel` is true.
345 2
     * - items: array, optional, the item's submenu items. The structure is the same as for `items` option.
346
     * - itemsContainerAttributes: array, optional, the HTML attributes for the item's submenu container.
347
     * - link: string, the item's href. Defaults to "#". For default `link` is "#".
348
     * - linkAttributes: array, the HTML attributes of the item's link. For default `linkAttributes` is `[]`.
349 13
     * - icon: string, the item's icon. For default is ``.
350
     * - iconAttributes: array, the HTML attributes of the item's icon. For default `iconAttributes` is `[]`.
351
     * - iconClass: string, the item's icon CSS class. For default is ``.
352 13
     * - liAttributes: array, optional, the HTML attributes of the item container.
353
     * - visible: bool, optional, whether this menu item is visible. Defaults to true.
354
     * - dropdown: array, optional, the configuration array for creating dropdown submenu items. The array structure is
355
     *   the same as the parent item configuration array.
356
     *
357
     * If a menu item is a string, it will be rendered directly without HTML encoding.
358
     *
359
     * @param array $values the list of items to be rendered.
360
     */
361
    public function items(array $values): self
362
    {
363
        $new = clone $this;
364
        $new->items = $values;
365 13
366
        return $new;
367 13
    }
368 11
369 11
    /**
370 11
     * Returns a new instance with the specified if enabled or disabled the items' container.
371
     *
372
     * @param bool $value The items container enable or disable, for default is `true`.
373
     */
374 5
    public function itemsContainer(bool $value): self
375 5
    {
376
        $new = clone $this;
377
        $new->itemsContainer = $value;
378
379
        return $new;
380
    }
381
382
    /**
383
     * Returns a new instance with the specified items' container attributes.
384
     *
385
     * @param array $values Attribute values indexed by attribute names.
386
     */
387 15
    public function itemsContainerAttributes(array $values): self
388
    {
389 15
        $new = clone $this;
390 14
        $new-> itemsContainerAttributes = $values;
391 2
392 2
        return $new;
393
    }
394
395 14
    /**
396 1
     * Returns a new instance with the specified items' container class.
397
     *
398
     * @param string $value The CSS class that will be assigned to the items' container.
399 14
     */
400 14
    public function itemsContainerClass(string $value): self
401 14
    {
402
        $new = clone $this;
403 14
        Html::addCssClass($new->itemsContainerAttributes, $value);
404 4
405
        return $new;
406 4
    }
407 1
408
    /**
409 1
     * Returns a new instance with the specified items tag.
410 1
     *
411 1
     * @param string $value The tag that will be used to wrap the items.
412
     */
413
    public function itemsTag(string $value): self
414
    {
415
        $new = clone $this;
416 13
        $new->itemsTag = $value;
417
418 12
        return $new;
419 12
    }
420
421 2
    /**
422
     * Returns a new instance with the specified last item CSS class.
423 12
     *
424
     * @param string $value The CSS class that will be assigned to the last item in the main menu or each submenu.
425 4
     */
426 1
    public function lastItemClass(string $value): self
427
    {
428
        $new = clone $this;
429 1
        $new->lastItemClass = $value;
430
431
        return $new;
432 4
    }
433 4
434
    /**
435
     * Returns a new instance with the specified link attributes.
436
     *
437 15
     * @param array $values Attribute values indexed by attribute names.
438
     */
439
    public function linkAttributes(array $values): self
440
    {
441
        $new = clone $this;
442
        $new->linkAttributes = $values;
443
444
        return $new;
445
    }
446
447
    /**
448
     * Returns a new instance with the specified link css class.
449 12
     *
450
     * @param string $value The CSS class that will be assigned to the link.
451
     */
452 12
    public function linkClass(string $value): self
453 12
    {
454 12
        $new = clone $this;
455 12
        $new->linkClass = $value;
456
457
        return $new;
458
    }
459
460
    /**
461
     * Returns a new instance with the specified link tag.
462
     *
463
     * @param string $value The tag that will be used to wrap the link.
464
     */
465
    public function linkTag(string $value): self
466
    {
467
        $new = clone $this;
468
        $new->linkTag = $value;
469
470
        return $new;
471
    }
472
473
    /**
474
     * Returns a new instance with the specified tag for rendering the menu.
475
     *
476
     * @param string $value The tag for rendering the menu.
477
     */
478
    public function tagName(string $value): self
479
    {
480
        $new = clone $this;
481
        $new->tagName = $value;
482
483
        return $new;
484
    }
485
486
    /**
487
     * Returns a new instance with the specified the template used to render the main menu.
488
     *
489
     * @param string $value The template used to render the main menu. In this template, the token `{items}` will be
490
     * replaced.
491
     */
492
    public function template(string $value): self
493
    {
494
        $new = clone $this;
495
        $new->template = $value;
496
497
        return $new;
498
    }
499
500
    /**
501
     * Renders the menu.
502
     *
503
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
504
     *
505
     * @return string The result of Widget execution to be outputted.
506
     */
507
    protected function run(): string
508
    {
509
        $items = Helper\Normalize::menu($this->items, $this->currentPath, $this->activateItems);
510
511
        if ($items === []) {
512
            return '';
513
        }
514
515
        return $this->renderMenu($items);
516
    }
517
518
    private function renderAfterContent(): string
519
    {
520
        if ($this->afterTag === '') {
521
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
522
        }
523
524
        return PHP_EOL .
525
            Html::normalTag($this->afterTag, $this->afterContent, $this->afterAttributes)
526
                ->encode(false)
527
                ->render();
528
    }
529
530
    private function renderBeforeContent(): string
531
    {
532
        if ($this->beforeTag === '') {
533
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
534
        }
535
536
        return Html::normalTag($this->beforeTag, $this->beforeContent, $this->beforeAttributes)
537
            ->encode(false)
538
            ->render();
539
    }
540
541
    /**
542
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
543
     */
544
    private function renderDropdown(array $items): string
545
    {
546
        $dropdownDefinitions = $this->dropdownDefinitions;
547
548
        if ($dropdownDefinitions === []) {
549
            $dropdownDefinitions = [
550
                'container()' => [false],
551
                'dividerClass()' => ['dropdown-divider'],
552
                'toggleAttributes()' => [
553
                    ['aria-expanded' => 'false', 'data-bs-toggle' => 'dropdown', 'role' => 'button'],
554
                ],
555
                'toggleType()' => ['link'],
556
            ];
557
        }
558
559
        $dropdown = Dropdown::widget($dropdownDefinitions)->items($items)->render();
0 ignored issues
show
Bug introduced by
The method items() does not exist on Yiisoft\Widget\Widget. It seems like you code against a sub-type of Yiisoft\Widget\Widget such as Yiisoft\Yii\Widgets\Menu or Yiisoft\Yii\Widgets\Breadcrumbs or Yiisoft\Yii\Widgets\Dropdown. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

559
        $dropdown = Dropdown::widget($dropdownDefinitions)->/** @scrutinizer ignore-call */ items($items)->render();
Loading history...
560
561
        if ($this->dropdownContainerTag === '') {
562
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
563
        }
564
565
        return match ($this->dropdownContainer) {
566
            true => Html::normalTag($this->dropdownContainerTag, $dropdown, $this->dropdownContainerAttributes)
567
                ->encode(false)
568
                ->render(),
569
            false => $dropdown,
570
        };
571
    }
572
573
    /**
574
     * Renders the content of a menu item.
575
     *
576
     * Note that the container and the sub-menus are not rendered here.
577
     *
578
     * @param array $item The menu item to be rendered. Please refer to {@see items} to see what data might be in the
579
     * item.
580
     *
581
     * @return string The rendering result.
582
     */
583
    private function renderItem(array $item): string
584
    {
585
        /** @var array */
586
        $linkAttributes = $item['linkAttributes'] ?? [];
587
        $linkAttributes = array_merge($linkAttributes, $this->linkAttributes);
588
589
        if ($this->linkClass !== '') {
590
            Html::addCssClass($linkAttributes, $this->linkClass);
591
        }
592
593
        if ($item['active']) {
594
            $linkAttributes['aria-current'] = 'page';
595
            Html::addCssClass($linkAttributes, $this->activeClass);
596
        }
597
598
        if ($item['disabled']) {
599
            Html::addCssClass($linkAttributes, $this->disabledClass);
600
        }
601
602
        if (isset($item['link']) && is_string($item['link'])) {
603
            $linkAttributes['href'] = $item['link'];
604
        }
605
606
        /**
607
         * @var string $item['label']
608
         * @var string $item['icon']
609
         * @var string $item['iconClass']
610
         * @var array $item['iconAttributes']
611
         * @var array $item['iconContainerAttributes']
612
         */
613
        $label = $this->renderLabel(
614
            $item['label'],
615
            $item['icon'],
616
            $item['iconClass'],
617
            $item['iconAttributes'],
618
            $item['iconContainerAttributes'],
619
        );
620
621
        if ($this->linkTag === '') {
622
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
623
        }
624
625
        return match (isset($linkAttributes['href'])) {
626
            true => Html::normalTag($this->linkTag, $label, $linkAttributes)->encode(false)->render(),
627
            false => $label,
628
        };
629
    }
630
631
    /**
632
     * Recursively renders the menu items (without the container tag).
633
     *
634
     * @param array $items The menu items to be rendered recursively.
635
     *
636
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
637
     */
638
    private function renderItems(array $items): string
639
    {
640
        $lines = [];
641
        $n = count($items);
642
643
        /** @psalm-var array[] $items  */
644
        foreach ($items as $i => $item) {
645
            if (isset($item['items'])) {
646
                /** @psalm-var array $item['items'] */
647
                $lines[] = strtr($this->template, ['{items}' => $this->renderDropdown([$item])]);
648
            } elseif ($item['visible']) {
649
                /** @psalm-var array|null $item['itemsContainerAttributes'] */
650
                $itemsContainerAttributes = array_merge(
651
                    $this->itemsContainerAttributes,
652
                    $item['itemsContainerAttributes'] ?? [],
653
                );
654
655
                if ($i === 0 && $this->firstItemClass !== '') {
656
                    Html::addCssClass($itemsContainerAttributes, $this->firstItemClass);
657
                }
658
659
                if ($i === $n - 1 && $this->lastItemClass !== '') {
660
                    Html::addCssClass($itemsContainerAttributes, $this->lastItemClass);
661
                }
662
663
                $menu = $this->renderItem($item);
664
665
                if ($this->itemsTag === '') {
666
                    throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
667
                }
668
669
                $lines[] = match ($this->itemsContainer) {
670
                    false => $menu,
671
                    default => strtr(
672
                        $this->template,
673
                        [
674
                            '{items}' => Html::normalTag($this->itemsTag, $menu, $itemsContainerAttributes)
675
                                ->encode(false)
676
                                ->render(),
677
                        ],
678
                    ),
679
                };
680
            }
681
        }
682
683
        return PHP_EOL . implode(PHP_EOL, $lines);
684
    }
685
686
    /**
687
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
688
     */
689
    private function renderMenu(array $items): string
690
    {
691
        $afterContent = '';
692
        $attributes = $this->attributes;
693
        $beforeContent = '';
694
695
        $content = $this->renderItems($items) . PHP_EOL;
696
697
        if ($this->beforeContent !== '') {
698
            $beforeContent = $this->renderBeforeContent() . PHP_EOL;
699
        }
700
701
        if ($this->afterContent !== '') {
702
            $afterContent = $this->renderAfterContent();
703
        }
704
705
        if ($this->tagName === '') {
706
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
707
        }
708
709
        return match ($this->container) {
710
            false => $beforeContent . trim($content) . $afterContent,
711
            default => $beforeContent .
712
                Html::normalTag($this->tagName, $content, $attributes)->encode(false) .
713
                $afterContent,
714
        };
715
    }
716
717
    private function renderLabel(
718
        string $label,
719
        string $icon,
720
        string $iconClass,
721
        array $iconAttributes = [],
722
        array $iconContainerAttributes = []
723
    ): string {
724
        $html = '';
725
        $iconContainerAttributes = array_merge($this->iconContainerAttributes, $iconContainerAttributes);
726
727
        if ($iconClass !== '') {
728
            Html::addCssClass($iconAttributes, $iconClass);
729
        }
730
731
        if ($icon !== '' || $iconAttributes !== [] || $iconClass !== '') {
732
            $i = I::tag()->addAttributes($iconAttributes)->content($icon)->encode(false)->render();
733
            $html = Span::tag()->addAttributes($iconContainerAttributes)->content($i)->encode(false)->render();
734
        }
735
736
        if ($label !== '') {
737
            $html .= $label;
738
        }
739
740
        return $html;
741
    }
742
}
743