Passed
Branch master (fbd372)
by Wilmer
02:37
created

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