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

Menu::lastItemCssClass()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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

557
        $dropdown = Dropdown::widget($dropdownDefinitions)->/** @scrutinizer ignore-call */ items($items)->render();
Loading history...
558
559
        if ($this->dropdownContainerTag === '') {
560
            throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
561
        }
562
563
        return match ($this->dropdownContainer) {
564
            true => Html::normalTag($this->dropdownContainerTag, $dropdown, $this->dropdownContainerAttributes)
565
                ->encode(false)
566
                ->render(),
567
            false => $dropdown,
568
        };
569
    }
570
571
    /**
572
     * Renders the content of a menu item.
573
     *
574
     * Note that the container and the sub-menus are not rendered here.
575
     *
576
     * @param array $item The menu item to be rendered. Please refer to {@see items} to see what data might be in the
577
     * item.
578
     *
579
     * @return string The rendering result.
580
     */
581
    private function renderItem(array $item): string
582
    {
583
        /** @var array */
584
        $linkAttributes = $item['linkAttributes'] ?? [];
585
        $linkAttributes = array_merge($linkAttributes, $this->linkAttributes);
586
        /** @var array */
587
        $iconContainerAttributes = $item['iconContainerAttributes'] ?? $this->iconContainerAttributes;
588
589
590
        if ($this->linkClass !== '') {
591
            Html::addCssClass($linkAttributes, $this->linkClass);
592
        }
593
594
        if ($item['active']) {
595
            $linkAttributes['aria-current'] = 'page';
596
            Html::addCssClass($linkAttributes, $this->activeClass);
597
        }
598
599
        if ($item['disabled']) {
600
            Html::addCssClass($linkAttributes, $this->disabledClass);
601
        }
602
603
        if (isset($item['link']) && is_string($item['link'])) {
604
            $linkAttributes['href'] = $item['link'];
605
        }
606
607
        /**
608
         * @var string $item['label']
609
         * @var string $item['icon']
610
         * @var array $item['iconAttributes']
611
         * @var string $item['iconClass']
612
         */
613
        $label = Helper\Normalize::renderLabel(
614
            $item['label'],
615
            $item['icon'],
616
            $item['iconAttributes'],
617
            $item['iconClass'],
618
            $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