Passed
Push — master ( 8a8b7b...c348f9 )
by Evgeniy
03:48
created

Menu::isItemActive()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

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

344
            $menu = $this->renderItem(/** @scrutinizer ignore-type */ $item);
Loading history...
345
346 13
            if (!empty($item['items'])) {
347 2
                $submenuTemplate = ArrayHelper::getValue($item, 'submenuTemplate', $this->submenuTemplate);
348 2
                $menu .= strtr($submenuTemplate, [
349 2
                    '{items}' => $this->renderItems($item['items']),
350
                ]);
351
            }
352
353 13
            $lines[] = empty($tag) ? $menu : Html::tag($tag, $menu, $options)->encode(false);
354
        }
355
356 13
        return implode("\n", $lines);
357
    }
358
359
    /**
360
     * Renders the content of a menu item.
361
     *
362
     * Note that the container and the sub-menus are not rendered here.
363
     *
364
     * @param array $item The menu item to be rendered. Please refer to {@see items} to see what data might be in the
365
     * item.
366
     *
367
     * @return string The rendering result.
368
     */
369 13
    private function renderItem(array $item): string
370
    {
371 13
        if (isset($item['url'])) {
372 11
            return strtr(ArrayHelper::getValue($item, 'template', $this->linkTemplate), [
373 11
                '{url}' => Html::encode($item['url']),
374 11
                '{label}' => $item['label'],
375
            ]);
376
        }
377
378 5
        return strtr(ArrayHelper::getValue($item, 'template', $this->labelTemplate), [
379 5
            '{label}' => $item['label'],
380
        ]);
381
    }
382
383
    /**
384
     * Normalizes the {@see items} property to remove invisible items and activate certain items.
385
     *
386
     * @param array $items The items to be normalized.
387
     * @param bool|null $active Whether there is an active child menu item.
388
     *
389
     * @return array The normalized menu items.
390
     */
391 15
    private function normalizeItems(array $items, ?bool &$active): array
392
    {
393 15
        foreach ($items as $i => $item) {
394 14
            if (isset($item['visible']) && !$item['visible']) {
395 2
                unset($items[$i]);
396 2
                continue;
397
            }
398
399 14
            if (!isset($item['label'])) {
400 1
                $item['label'] = '';
401
            }
402
403 14
            $encodeLabel = $item['encode'] ?? $this->encodeLabels;
404 14
            $items[$i]['label'] = $encodeLabel ? Html::encode($item['label']) : $item['label'];
405 14
            $hasActiveChild = false;
406
407 14
            if (isset($item['items'])) {
408 4
                $items[$i]['items'] = $this->normalizeItems($item['items'], $hasActiveChild);
409
410 4
                if (empty($items[$i]['items']) && !$this->showEmptyItems) {
411 1
                    unset($items[$i]['items']);
412
413 1
                    if (!isset($item['url'])) {
414 1
                        unset($items[$i]);
415 1
                        continue;
416
                    }
417
                }
418
            }
419
420 13
            if (!isset($item['active'])) {
421
                if (
422 12
                    ($this->activateParents && $hasActiveChild)
423 12
                    || ($this->activateItems && $this->isItemActive($item))
424
                ) {
425 2
                    $active = $items[$i]['active'] = true;
426
                } else {
427 12
                    $items[$i]['active'] = false;
428
                }
429 4
            } elseif ($item['active'] instanceof Closure) {
430 1
                $active = $items[$i]['active'] = $item['active'](
431 1
                    $item,
432
                    $hasActiveChild,
433 1
                    $this->isItemActive($item),
434
                    $this,
435
                );
436 4
            } elseif ($item['active']) {
437 4
                $active = true;
438
            }
439
        }
440
441 15
        return array_values($items);
442
    }
443
444
    /**
445
     * Checks whether a menu item is active.
446
     *
447
     * This is done by checking match that specified in the `url` option of the menu item.
448
     *
449
     * @param array $item The menu item to be checked.
450
     *
451
     * @return bool Whether the menu item is active.
452
     */
453 12
    private function isItemActive(array $item): bool
454
    {
455
        return
456 12
            $this->activateItems
457 12
            && $this->currentPath !== '/'
458 12
            && isset($item['url'])
459 12
            && $item['url'] === $this->currentPath
460
        ;
461
    }
462
}
463