Passed
Push — master ( b3e21b...2e544e )
by Alexander
01:42
created

Menu::isItemActive()   A

Complexity

Conditions 5
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 5.0729

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 7
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 12
ccs 6
cts 7
cp 0.8571
crap 5.0729
rs 9.6111
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Widgets;
6
7
use Closure;
8
use JsonException;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Html\Html;
11
use Yiisoft\Widget\Widget;
12
13
use function array_merge;
14
use function array_values;
15
use function call_user_func;
16
use function count;
17
use function implode;
18
use function strtr;
19
20
/**
21
 * Menu displays a multi-level menu using nested HTML lists.
22
 *
23
 * The main property of Menu is {@see items}, which specifies the possible items in the menu. A menu item can contain
24
 * sub-items which specify the sub-menu under that menu item.
25
 *
26
 * Menu checks the current route and request parameters to toggle certain menu items with active state.
27
 *
28
 * Note that Menu only renders the HTML tags about the menu. It does not do any styling. You are responsible to provide
29
 * CSS styles to make it look like a real menu.
30
 *
31
 * The following example shows how to use Menu:
32
 *
33
 * ```php
34
 * echo Menu::Widget()
35
 *     ->items([
36
 *         ['label' => 'Login', 'url' => 'site/login', 'visible' => true],
37
 *     ]);
38
 * ```
39
 */
40
final class Menu extends Widget
41
{
42
    /**
43
     * @var array list of menu items. Each menu item should be an array of the following structure:
44
     *
45
     * - label: string, optional, specifies the menu item label. When {@see encodeLabels} is true, the label will be
46
     *   HTML-encoded. If the label is not specified, an empty string will be used.
47
     * - encode: bool, optional, whether this item`s label should be HTML-encoded. This param will override global
48
     *   {@see encodeLabels} param.
49
     * - url: string or array, optional, specifies the URL of the menu item. When this is set, the actual menu item
50
     *   content will be generated using {@see linkTemplate}; otherwise, {@see labelTemplate} will be used.
51
     * - visible: bool, optional, whether this menu item is visible. Defaults to true.
52
     * - items: array, optional, specifies the sub-menu items. Its format is the same as the parent items.
53
     * - active: bool or Closure, optional, whether this menu item is in active state (currently selected). When
54
     *   using a closure, its signature should be `function ($item, $hasActiveChild, $isItemActive, $Widget)`. Closure
55
     *   must return `true` if item should be marked as `active`, otherwise - `false`. If a menu item is active, its CSS
56
     *   class will be appended with {@see activeCssClass}. If this option is not set, the menu item will be set active
57
     *   automatically when the current request is triggered by `url`. For more details, please refer to
58
     *   {@see isItemActive()}.
59
     * - template: string, optional, the template used to render the content of this menu item. The token `{url}` will
60
     *   be replaced by the URL associated with this menu item, and the token `{label}` will be replaced by the label
61
     *   of the menu item. If this option is not set, {@see linkTemplate} or {@see labelTemplate} will be used instead.
62
     * - submenuTemplate: string, optional, the template used to render the list of sub-menus. The token `{items}` will
63
     *   be replaced with the rendered sub-menu items. If this option is not set, [[submenuTemplate]] will be used
64
     *   instead.
65
     * - options: array, optional, the HTML attributes for the menu container tag.
66
     */
67
    private array $items = [];
68
69
    /**
70
     * @var array list of HTML attributes shared by all menu {@see items}. If any individual menu item specifies its
71
     * `options`, it will be merged with this property before being used to generate the HTML attributes for the menu
72
     * item tag. The following special options are recognized:
73
     *
74
     * - tag: string, defaults to "li", the tag name of the item container tags. Set to false to disable container tag.
75
     *   See also {@see \Yiisoft\Html\Html::tag()}
76
     *
77
     * {@see \Yiisoft\Html\Html::renderTagAttributes() for details on how attributes are being rendered}
78
     */
79
    private array $itemOptions = [];
80
81
    /**
82
     * @var string the template used to render the body of a menu which is a link. In this template, the token `{url}`
83
     * will be replaced with the corresponding link URL; while `{label}` will be replaced with the link text.
84
     *
85
     * This property will be overridden by the `template` option set in individual menu items via {@see items}.
86
     */
87
    private string $linkTemplate = '<a href="{url}">{label}</a>';
88
89
    /**
90
     * @var string the template used to render the body of a menu which is NOT a link.
91
     *
92
     * In this template, the token `{label}` will be replaced with the label of the menu item.
93
     *
94
     * This property will be overridden by the `template` option set in individual menu items via {@see items}.
95
     */
96
    private string $labelTemplate = '{label}';
97
98
    /**
99
     * @var string the template used to render a list of sub-menus.
100
     *
101
     * In this template, the token `{items}` will be replaced with the rendered sub-menu items.
102
     */
103
    private string $submenuTemplate = "\n<ul>\n{items}\n</ul>\n";
104
105
    /**
106
     * @var bool whether the labels for menu items should be HTML-encoded.
107
     */
108
    private bool $encodeLabels = true;
109
110
    /**
111
     * @var string the CSS class to be appended to the active menu item.
112
     */
113
    private string $activeCssClass = 'active';
114
115
    /**
116
     * @var bool whether to automatically activate items according to whether their route setting matches the currently
117
     * requested route.
118
     *
119
     * {@see isItemActive()}
120
     */
121
    private bool $activateItems = true;
122
123
    /**
124
     * @var bool whether to activate parent menu items when one of the corresponding child menu items is active. The
125
     * activated parent menu items will also have its CSS classes appended with {@see activeCssClass}.
126
     */
127
    private bool $activateParents = false;
128
129
    /**
130
     * @var string|null $currentPath Allows you to assign the current path of the url from request controller.
131
     */
132
    private ?string $currentPath = null;
133
134
    /**
135
     * @var bool whether to hide empty menu items. An empty menu item is one whose `url` option is not set and which has
136
     * no visible child menu items.
137
     */
138
    private bool $hideEmptyItems = true;
139
140
    /**
141
     * @var array the HTML attributes for the menu's container tag. The following special options are recognized:
142
     *
143
     * - tag: string, defaults to "ul", the tag name of the item container tags. Set to false to disable container tag.
144
     *   See also {@see \Yiisoft\Html\Html::tag()}.
145
     *
146
     * {@see \Yiisoft\Html\Html::renderTagAttributes()} for details on how attributes are being rendered.
147
     */
148
    private array $options = [];
149
150
    /**
151
     * @var string|null the CSS class that will be assigned to the first item in the main menu or each submenu. Defaults to
152
     * null, meaning no such CSS class will be assigned.
153
     */
154
    private ?string $firstItemCssClass = null;
155
156
    /**
157
     * @var string|null the CSS class that will be assigned to the last item in the main menu or each submenu. Defaults to
158
     * null, meaning no such CSS class will be assigned.
159
     */
160
    private ?string $lastItemCssClass = null;
161
162
    /**
163
     * Renders the menu.
164
     *
165
     * @throws JsonException
166
     *
167
     * @return string the result of Widget execution to be outputted.
168
     */
169 7
    public function run(): string
170
    {
171 7
        $items = $this->normalizeItems($this->items, $hasActiveChild);
172
173 7
        if (empty($items)) {
174
            return '';
175
        }
176
177 7
        $options = $this->options;
178 7
        $tag = ArrayHelper::remove($options, 'tag', 'ul');
179
180 7
        return Html::tag($tag, $this->renderItems($items), $options);
181
    }
182
183
    /**
184
     * {@see $activateItems}
185
     *
186
     * @param bool $value
187
     *
188
     * @return $this
189
     */
190
    public function activateItems(bool $value): self
191
    {
192
        $this->activateItems = $value;
193
194
        return $this;
195
    }
196
197
    /**
198
     * {@see $activateParents}
199
     *
200
     * @param bool $value
201
     *
202
     * @return $this
203
     */
204
    public function activateParents(bool $value): self
205
    {
206
        $this->activateParents = $value;
207
208
        return $this;
209
    }
210
211
    /**
212
     * {@see $activeCssClass}
213
     *
214
     * @param string $value
215
     *
216
     * @return $this
217
     */
218 2
    public function activeCssClass(string $value): self
219
    {
220 2
        $this->activeCssClass = $value;
221
222 2
        return $this;
223
    }
224
225
    /**
226
     * {@see $currentPath}
227
     *
228
     * @param string $value
229
     *
230
     * @return $this
231
     */
232
    public function currentPath(string $value): self
233
    {
234
        $this->currentPath = $value;
235
236
        return $this;
237
    }
238
239
    /**
240
     * {@see $encodeLabels}
241
     *
242
     * @param bool $value
243
     *
244
     * @return $this
245
     */
246 5
    public function encodeLabels(bool $value): self
247
    {
248 5
        $this->encodeLabels = $value;
249
250 5
        return $this;
251
    }
252
253
    /**
254
     * {@see $firstItemCssClass}
255
     *
256
     * @param string $value
257
     *
258
     * @return $this
259
     */
260
    public function firstItemCssClass(string $value): self
261
    {
262
        $this->firstItemCssClass = $value;
263
264
        return $this;
265
    }
266
267
    /**
268
     * {@see $hideEmptyItems}
269
     *
270
     * @param bool $value
271
     *
272
     * @return $this
273
     */
274
    public function hideEmptyItems(bool $value): self
275
    {
276
        $this->hideEmptyItems = $value;
277
278
        return $this;
279
    }
280
281
    /**
282
     * {@see $items}
283
     *
284
     * @param array $value
285
     *
286
     * @return $this
287
     */
288 7
    public function items(array $value): self
289
    {
290 7
        $this->items = $value;
291
292 7
        return $this;
293
    }
294
295
    /**
296
     * {@see $itemOptions}
297
     *
298
     * @param array $value
299
     *
300
     * @return $this
301
     */
302 1
    public function itemOptions(array $value): self
303
    {
304 1
        $this->itemOptions = $value;
305
306 1
        return $this;
307
    }
308
309
    /**
310
     * {@see $labelTemplate}
311
     *
312
     * @param string $value
313
     *
314
     * @return $this
315
     */
316 2
    public function labelTemplate(string $value): self
317
    {
318 2
        $this->labelTemplate = $value;
319
320 2
        return $this;
321
    }
322
323
    /**
324
     * {@see $lastItemCssClass}
325
     *
326
     * @param string $value
327
     *
328
     * @return $this
329
     */
330
    public function lastItemCssClass(string $value): self
331
    {
332
        $this->lastItemCssClass = $value;
333
334
        return $this;
335
    }
336
337
    /**
338
     * {@see $linkTemplate}
339
     *
340
     * @param string $value
341
     *
342
     * @return $this
343
     */
344 2
    public function linkTemplate(string $value): self
345
    {
346 2
        $this->linkTemplate = $value;
347
348 2
        return $this;
349
    }
350
351
    /**
352
     * {@see $options}
353
     *
354
     * @param array $value
355
     *
356
     * @return $this
357
     */
358 1
    public function options(array $value): self
359
    {
360 1
        $this->options = $value;
361
362 1
        return $this;
363
    }
364
365
    /**
366
     * Recursively renders the menu items (without the container tag).
367
     *
368
     * @param array $items the menu items to be rendered recursively
369
     *
370
     * @throws JsonException
371
     *
372
     * @return string the rendering result
373
     */
374 7
    protected function renderItems(array $items): string
375
    {
376 7
        $n = count($items);
377 7
        $lines = [];
378
379 7
        foreach ($items as $i => $item) {
380 7
            $options = array_merge($this->itemOptions, ArrayHelper::getValue($item, 'options', []));
381 7
            $tag = ArrayHelper::remove($options, 'tag', 'li');
382 7
            $class = [];
383
384 7
            if ($item['active']) {
385 3
                $class[] = $this->activeCssClass;
386
            }
387
388 7
            if ($i === 0 && $this->firstItemCssClass !== null) {
389
                $class[] = $this->firstItemCssClass;
390
            }
391
392 7
            if ($i === $n - 1 && $this->lastItemCssClass !== null) {
393
                $class[] = $this->lastItemCssClass;
394
            }
395
396 7
            Html::addCssClass($options, $class);
397
398 7
            $menu = $this->renderItem($item);
0 ignored issues
show
Bug introduced by
It seems like $item can also be of type object; however, parameter $item of Yiisoft\Yii\Widgets\Menu::renderItem() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

398
            $menu = $this->renderItem(/** @scrutinizer ignore-type */ $item);
Loading history...
399
400 7
            if (!empty($item['items'])) {
401
                $submenuTemplate = ArrayHelper::getValue($item, 'submenuTemplate', $this->submenuTemplate);
402
                $menu .= strtr($submenuTemplate, [
403
                    '{items}' => $this->renderItems($item['items']),
404
                ]);
405
            }
406
407 7
            $lines[] = Html::tag($tag, $menu, $options);
408
        }
409
410 7
        return implode("\n", $lines);
411
    }
412
413
    /**
414
     * Renders the content of a menu item.
415
     *
416
     * Note that the container and the sub-menus are not rendered here.
417
     *
418
     * @param array $item the menu item to be rendered. Please refer to {@see items} to see what data might be in the
419
     * item.
420
     *
421
     * @return string the rendering result
422
     */
423 7
    protected function renderItem(array $item): string
424
    {
425 7
        if (isset($item['url'])) {
426 7
            $template = ArrayHelper::getValue($item, 'template', $this->linkTemplate);
427
428 7
            return strtr($template, [
429 7
                '{url}'   => Html::encode($item['url']),
430 7
                '{label}' => $item['label'],
431
            ]);
432
        }
433
434 2
        $template = ArrayHelper::getValue($item, 'template', $this->labelTemplate);
435
436 2
        return strtr($template, [
437 2
            '{label}' => $item['label'],
438
        ]);
439
    }
440
441
    /**
442
     * Normalizes the {@see items} property to remove invisible items and activate certain items.
443
     *
444
     * @param array $items  the items to be normalized.
445
     * @param bool|null $active whether there is an active child menu item.
446
     *
447
     * @return array the normalized menu items
448
     */
449 7
    protected function normalizeItems(array $items, ?bool &$active): array
450
    {
451 7
        foreach ($items as $i => $item) {
452 7
            if (isset($item['visible']) && !$item['visible']) {
453
                unset($items[$i]);
454
                continue;
455
            }
456
457 7
            if (!isset($item['label'])) {
458
                $item['label'] = '';
459
            }
460
461 7
            $encodeLabel = $item['encode'] ?? $this->encodeLabels;
462 7
            $items[$i]['label'] = $encodeLabel ? Html::encode($item['label']) : $item['label'];
463 7
            $hasActiveChild = false;
464
465 7
            if (isset($item['items'])) {
466
                $items[$i]['items'] = $this->normalizeItems($item['items'], $hasActiveChild);
467
                if (empty($items[$i]['items']) && $this->hideEmptyItems) {
468
                    unset($items[$i]['items']);
469
                    if (!isset($item['url'])) {
470
                        unset($items[$i]);
471
                        continue;
472
                    }
473
                }
474
            }
475
476 7
            if (!isset($item['active'])) {
477
                if (
478 6
                    ($this->activateParents && $hasActiveChild)
479 6
                    || ($this->activateItems && $this->isItemActive($item))
480
                ) {
481
                    $active = $items[$i]['active'] = true;
482
                } else {
483 6
                    $items[$i]['active'] = false;
484
                }
485 3
            } elseif ($item['active'] instanceof Closure) {
486 1
                $active = $items[$i]['active'] = call_user_func(
487 1
                    $item['active'],
488 1
                    $item,
489 1
                    $hasActiveChild,
490 1
                    $this->isItemActive($item),
491 1
                    $this
492
                );
493 3
            } elseif ($item['active']) {
494 3
                $active = true;
495
            }
496
        }
497
498 7
        return array_values($items);
499
    }
500
501
    /**
502
     * Checks whether a menu item is active.
503
     *
504
     * This is done by checking match that specified in the `url` option of the menu item.
505
     *
506
     * Only when 'url' match $_SERVER['REQUEST_URI'] respectively, will a menu item be considered active.
507
     *
508
     * @param array $item the menu item to be checked
509
     * @param bool $active returns the result when the item is active
510
     *
511
     * @return bool whether the menu item is active
512
     */
513 7
    protected function isItemActive(array $item, bool $active = false): bool
514
    {
515
        if (
516 7
            $this->activateItems
517 7
            && $this->currentPath !== '/'
518 7
            && isset($item['url'])
519 7
            && $item['url'] === $this->currentPath
520
        ) {
521
            $active = true;
522
        }
523
524 7
        return $active;
525
    }
526
}
527