Passed
Pull Request — master (#53)
by Wilmer
07:05 queued 04:41
created

Menu::linkClass()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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

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

628
                        /** @scrutinizer ignore-call */ 
629
                        $items[$i]['active'] = self::isItemActive($link, $currentPath, $activateItems);
Loading history...
629
                    }
630
631
                    /** @var bool */
632 25
                    $items[$i]['disabled'] = $child['disabled'] ?? false;
633
                    /** @var bool */
634 25
                    $items[$i]['encodeLabel'] = $child['encodeLabel'] ?? true;
635
                    /** @var string */
636 25
                    $items[$i]['icon'] = $child['icon'] ?? '';
637
                    /** @var array */
638 25
                    $items[$i]['iconAttributes'] = $child['iconAttributes'] ?? [];
639
                    /** @var string */
640 25
                    $items[$i]['iconClass'] = $child['iconClass'] ?? '';
641
                    /** @var array */
642 25
                    $items[$i]['iconContainerAttributes'] = $child['iconContainerAttributes'] ?? [];
643
                    /** @var bool */
644 25
                    $items[$i]['visible'] = $child['visible'] ?? true;
645
                }
646
            }
647
        }
648
649 26
        return $items;
650
    }
651
652 2
    private function renderAfterContent(): string
653
    {
654 2
        if ($this->afterTag === '') {
655 1
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
656
        }
657
658 1
        return PHP_EOL .
659 1
            Html::normalTag($this->afterTag, $this->afterContent, $this->afterAttributes)
660 1
                ->encode(false)
661 1
                ->render();
662
    }
663
664 2
    private function renderBeforeContent(): string
665
    {
666 2
        if ($this->beforeTag === '') {
667 1
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
668
        }
669
670 1
        return Html::normalTag($this->beforeTag, $this->beforeContent, $this->beforeAttributes)
671 1
            ->encode(false)
672 1
            ->render();
673
    }
674
675
    /**
676
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
677
     */
678 4
    private function renderDropdown(array $items): string
679
    {
680 4
        $dropdownDefinitions = $this->dropdownDefinitions;
681
682 4
        if ($dropdownDefinitions === []) {
683 3
            $dropdownDefinitions = [
684
                'container()' => [false],
685
                'dividerClass()' => ['dropdown-divider'],
686
                'toggleAttributes()' => [
687
                    ['aria-expanded' => 'false', 'data-bs-toggle' => 'dropdown', 'role' => 'button'],
688
                ],
689
                'toggleType()' => ['link'],
690
            ];
691
        }
692
693 4
        $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

693
        $dropdown = Dropdown::widget($dropdownDefinitions)->/** @scrutinizer ignore-call */ items($items)->render();
Loading history...
694
695 4
        if ($this->dropdownContainerTag === '') {
696 1
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
697
        }
698
699 3
        return match ($this->dropdownContainer) {
700 3
            true => Html::normalTag($this->dropdownContainerTag, $dropdown, $this->dropdownContainerAttributes)
701 3
                ->encode(false)
702 3
                ->render(),
703 3
            false => $dropdown,
704
        };
705
    }
706
707
    /**
708
     * Renders the content of a menu item.
709
     *
710
     * Note that the container and the sub-menus are not rendered here.
711
     *
712
     * @param array $item The menu item to be rendered. Please refer to {@see items} to see what data might be in the
713
     * item.
714
     *
715
     * @return string The rendering result.
716
     */
717 24
    private function renderItem(array $item): string
718
    {
719
        /** @var string */
720 24
        $label = $item['label'] ?? '';
721
        /** @var array */
722 24
        $linkAttributes = $item['linkAttributes'] ?? [];
723 24
        $linkAttributes = array_merge($linkAttributes, $this->linkAttributes);
724
725 24
        if ($this->linkClass !== '') {
726 1
            Html::addCssClass($linkAttributes, $this->linkClass);
727
        }
728
729 24
        if ($item['active']) {
730 6
            $linkAttributes['aria-current'] = 'page';
731 6
            Html::addCssClass($linkAttributes, $this->activeClass);
732
        }
733
734 24
        if ($item['disabled']) {
735 12
            Html::addCssClass($linkAttributes, $this->disabledClass);
736
        }
737
738 24
        if ($item['encodeLabel']) {
739 24
            $label = Html::encode($label);
740
        }
741
742 24
        if (isset($item['link'])) {
743 19
            $linkAttributes['href'] = $item['link'];
744
        }
745
746
        /**
747
         * @var string $item['icon']
748
         * @var string $item['iconClass']
749
         * @var array $item['iconAttributes']
750
         * @var array $item['iconContainerAttributes']
751
         */
752 24
        $label = $this->renderLabel(
753
            $label,
754 24
            $item['icon'],
755 24
            $item['iconClass'],
756 24
            $item['iconAttributes'],
757 24
            $item['iconContainerAttributes'],
758
        );
759
760 24
        if ($this->linkTag === '') {
761 1
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
762
        }
763
764 23
        return match (isset($linkAttributes['href'])) {
765 19
            true => Html::normalTag($this->linkTag, $label, $linkAttributes)->encode(false)->render(),
766 23
            false => $label,
767
        };
768
    }
769
770
    /**
771
     * Recursively renders the menu items (without the container tag).
772
     *
773
     * @param array $items The menu items to be rendered recursively.
774
     *
775
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
776
     */
777 25
    private function renderItems(array $items): string
778
    {
779 25
        $lines = [];
780 25
        $n = count($items);
781
782
        /** @psalm-var array[] $items  */
783 25
        foreach ($items as $i => $item) {
784 25
            if (isset($item['items'])) {
785
                /** @psalm-var array $item['items'] */
786 4
                $lines[] = strtr($this->template, ['{items}' => $this->renderDropdown([$item])]);
787 24
            } elseif ($item['visible']) {
788
                /** @psalm-var array|null $item['itemsContainerAttributes'] */
789 24
                $itemsContainerAttributes = array_merge(
790 24
                    $this->itemsContainerAttributes,
791 24
                    $item['itemsContainerAttributes'] ?? [],
792
                );
793
794 24
                if ($i === 0 && $this->firstItemClass !== '') {
795 1
                    Html::addCssClass($itemsContainerAttributes, $this->firstItemClass);
796
                }
797
798 24
                if ($i === $n - 1 && $this->lastItemClass !== '') {
799 1
                    Html::addCssClass($itemsContainerAttributes, $this->lastItemClass);
800
                }
801
802 24
                $menu = $this->renderItem($item);
803
804 23
                if ($this->itemsTag === '') {
805 1
                    throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
806
                }
807
808 22
                $lines[] = match ($this->itemsContainer) {
809 1
                    false => $menu,
810 21
                    default => strtr(
811 21
                        $this->template,
812
                        [
813 21
                            '{items}' => Html::normalTag($this->itemsTag, $menu, $itemsContainerAttributes)
814 21
                                ->encode(false)
815 21
                                ->render(),
816
                        ],
817
                    ),
818
                };
819
            }
820
        }
821
822 22
        return PHP_EOL . implode(PHP_EOL, $lines);
823
    }
824
825
    /**
826
     * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
827
     */
828 25
    private function renderMenu(array $items): string
829
    {
830 25
        $afterContent = '';
831 25
        $attributes = $this->attributes;
832 25
        $beforeContent = '';
833
834 25
        $content = $this->renderItems($items) . PHP_EOL;
835
836 22
        if ($this->beforeContent !== '') {
837 2
            $beforeContent = $this->renderBeforeContent() . PHP_EOL;
838
        }
839
840 21
        if ($this->afterContent !== '') {
841 2
            $afterContent = $this->renderAfterContent();
842
        }
843
844 20
        if ($this->tagName === '') {
845 1
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
846
        }
847
848 19
        return match ($this->container) {
849 1
            false => $beforeContent . trim($content) . $afterContent,
850
            default => $beforeContent .
851 18
                Html::normalTag($this->tagName, $content, $attributes)->encode(false) .
852
                $afterContent,
853
        };
854
    }
855
856 24
    private function renderLabel(
857
        string $label,
858
        string $icon,
859
        string $iconClass,
860
        array $iconAttributes = [],
861
        array $iconContainerAttributes = []
862
    ): string {
863 24
        $html = '';
864 24
        $iconContainerAttributes = array_merge($this->iconContainerAttributes, $iconContainerAttributes);
865
866 24
        if ($iconClass !== '') {
867 1
            Html::addCssClass($iconAttributes, $iconClass);
868
        }
869
870 24
        if ($icon !== '' || $iconClass !== '') {
871 1
            $i = I::tag()->addAttributes($iconAttributes)->content($icon)->encode(false)->render();
872 1
            $html = Span::tag()->addAttributes($iconContainerAttributes)->content($i)->encode(false)->render();
873
        }
874
875 24
        if ($label !== '') {
876 24
            $html .= $label;
877
        }
878
879 24
        return $html;
880
    }
881
}
882