Passed
Pull Request — master (#79)
by Wilmer
02:24
created

Menu::render()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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