Passed
Push — master ( fd7f0a...16b7fb )
by Alexander
02:29
created

src/Menu.php (1 issue)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Widgets;
6
7
use function array_merge;
8
use function array_values;
9
use function call_user_func;
10
use Closure;
11
use function count;
12
13
use function implode;
14
use JsonException;
15
use function strtr;
16
use Yiisoft\Arrays\ArrayHelper;
17
use Yiisoft\Html\Html;
18
use Yiisoft\Widget\Widget;
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 string the template used to render a list of sub-menus.
44
     *
45
     * In this template, the token `{items}` will be replaced with the rendered sub-menu items.
46
     */
47
    private string $submenuTemplate = "\n<ul>\n{items}\n</ul>\n";
48
    private array $items = [];
49
    private array $itemOptions = [];
50
    private string $linkTemplate = '<a href="{url}">{label}</a>';
51
    private string $labelTemplate = '{label}';
52
    private bool $encodeLabels = true;
53
    private string $activeCssClass = 'active';
54
    private bool $activateItems = true;
55
    private bool $activateParents = false;
56
    private ?string $currentPath = null;
57
    private bool $hideEmptyItems = true;
58
    private array $options = [];
59
    private ?string $firstItemCssClass = null;
60
    private ?string $lastItemCssClass = null;
61
62
    /**
63
     * Renders the menu.
64
     *
65
     * @throws JsonException
66
     *
67
     * @return string the result of Widget execution to be outputted.
68
     */
69 7
    protected function run(): string
70
    {
71 7
        $items = $this->normalizeItems($this->items, $hasActiveChild);
72
73 7
        if (empty($items)) {
74
            return '';
75
        }
76
77 7
        $options = $this->options;
78 7
        $tag = ArrayHelper::remove($options, 'tag', 'ul');
79
80 7
        return empty($tag)
81 1
            ? $this->renderItems($items)
82 7
            : Html::tag($tag, $this->renderItems($items), $options)->encode(false)->render()
83
        ;
84
    }
85
86
    /**
87
     * @param bool $value whether to automatically activate items according to whether their route setting matches the
88
     * currently requested route.
89
     *
90
     * @return $this
91
     */
92
    public function activateItems(bool $value): self
93
    {
94
        $this->activateItems = $value;
95
96
        return $this;
97
    }
98
99
    /**
100
     * @param bool $value whether to activate parent menu items when one of the corresponding child menu items is
101
     * active. The activated parent menu items will also have its CSS classes appended with {@see activeCssClass}.
102
     *
103
     * @return $this
104
     */
105
    public function activateParents(bool $value): self
106
    {
107
        $this->activateParents = $value;
108
109
        return $this;
110
    }
111
112
    /**
113
     * @param string $value the CSS class to be appended to the active menu item.
114
     *
115
     * @return $this
116
     */
117 2
    public function activeCssClass(string $value): self
118
    {
119 2
        $this->activeCssClass = $value;
120
121 2
        return $this;
122
    }
123
124
    /**
125
     * @param string|null $value allows you to assign the current path of the url from request controller.
126
     *
127
     * @return $this
128
     */
129
    public function currentPath(?string $value): self
130
    {
131
        $this->currentPath = $value;
132
133
        return $this;
134
    }
135
136
    /**
137
     * @param bool $value whether the labels for menu items should be HTML-encoded.
138
     *
139
     * @return $this
140
     */
141 5
    public function encodeLabels(bool $value): self
142
    {
143 5
        $this->encodeLabels = $value;
144
145 5
        return $this;
146
    }
147
148
    /**
149
     * @param string|null $value the CSS class that will be assigned to the first item in the main menu or each submenu.
150
     * Defaults to null, meaning no such CSS class will be assigned.
151
     *
152
     * @return $this
153
     */
154
    public function firstItemCssClass(?string $value): self
155
    {
156
        $this->firstItemCssClass = $value;
157
158
        return $this;
159
    }
160
161
    /**
162
     * @param bool $value whether to hide empty menu items. An empty menu item is one whose `url` option is not set and
163
     * which has no visible child menu items.
164
     *
165
     * @return $this
166
     */
167
    public function hideEmptyItems(bool $value): self
168
    {
169
        $this->hideEmptyItems = $value;
170
171
        return $this;
172
    }
173
174
    /**
175
     * @param array $value list of menu items. Each menu item should be an array of the following structure:
176
     *
177
     * - label: string, optional, specifies the menu item label. When {@see encodeLabels} is true, the label will be
178
     *   HTML-encoded. If the label is not specified, an empty string will be used.
179
     * - encode: bool, optional, whether this item`s label should be HTML-encoded. This param will override global
180
     *   {@see encodeLabels} param.
181
     * - url: string or array, optional, specifies the URL of the menu item. When this is set, the actual menu item
182
     *   content will be generated using {@see linkTemplate}; otherwise, {@see labelTemplate} will be used.
183
     * - visible: bool, optional, whether this menu item is visible. Defaults to true.
184
     * - items: array, optional, specifies the sub-menu items. Its format is the same as the parent items.
185
     * - active: bool or Closure, optional, whether this menu item is in active state (currently selected). When
186
     *   using a closure, its signature should be `function ($item, $hasActiveChild, $isItemActive, $Widget)`. Closure
187
     *   must return `true` if item should be marked as `active`, otherwise - `false`. If a menu item is active, its CSS
188
     *   class will be appended with {@see activeCssClass}. If this option is not set, the menu item will be set active
189
     *   automatically when the current request is triggered by `url`. For more details, please refer to
190
     *   {@see isItemActive()}.
191
     * - template: string, optional, the template used to render the content of this menu item. The token `{url}` will
192
     *   be replaced by the URL associated with this menu item, and the token `{label}` will be replaced by the label
193
     *   of the menu item. If this option is not set, {@see linkTemplate} or {@see labelTemplate} will be used instead.
194
     * - submenuTemplate: string, optional, the template used to render the list of sub-menus. The token `{items}` will
195
     *   be replaced with the rendered sub-menu items. If this option is not set, {@see submenuTemplate} will be used
196
     *   instead.
197
     * - options: array, optional, the HTML attributes for the menu container tag.
198
     *
199
     * @return $this
200
     */
201 7
    public function items(array $value): self
202
    {
203 7
        $this->items = $value;
204
205 7
        return $this;
206
    }
207
208
    /**
209
     * @param array $value list of HTML attributes shared by all menu {@see items}. If any individual menu item
210
     * specifies its `options`, it will be merged with this property before being used to generate the HTML attributes
211
     * for the menu item tag. The following special options are recognized:
212
     *
213
     * - tag: string, defaults to "li", the tag name of the item container tags. Set to false to disable container tag.
214
     *   See also {@see \Yiisoft\Html\Html::tag()}
215
     *
216
     * @return $this
217
     *
218
     * {@see \Yiisoft\Html\Html::renderTagAttributes() for details on how attributes are being rendered}
219
     */
220 1
    public function itemOptions(array $value): self
221
    {
222 1
        $this->itemOptions = $value;
223
224 1
        return $this;
225
    }
226
227
    /**
228
     * @param string $value the template used to render the body of a menu which is NOT a link.
229
     *
230
     * In this template, the token `{label}` will be replaced with the label of the menu item.
231
     *
232
     * This property will be overridden by the `template` option set in individual menu items via {@see items}.
233
     *
234
     * @return $this
235
     */
236 2
    public function labelTemplate(string $value): self
237
    {
238 2
        $this->labelTemplate = $value;
239
240 2
        return $this;
241
    }
242
243
    /**
244
     * @param string|null $value the CSS class that will be assigned to the last item in the main menu or each submenu.
245
     * Defaults to null, meaning no such CSS class will be assigned.
246
     *
247
     * @return $this
248
     */
249
    public function lastItemCssClass(?string $value): self
250
    {
251
        $this->lastItemCssClass = $value;
252
253
        return $this;
254
    }
255
256
    /**
257
     * @param string $value the template used to render the body of a menu which is a link. In this template, the token
258
     * `{url}` will be replaced with the corresponding link URL; while `{label}` will be replaced with the link text.
259
     *
260
     * This property will be overridden by the `template` option set in individual menu items via {@see items}.
261
     *
262
     * @return $this
263
     */
264 2
    public function linkTemplate(string $value): self
265
    {
266 2
        $this->linkTemplate = $value;
267
268 2
        return $this;
269
    }
270
271
    /**
272
     * @param array $value the HTML attributes for the menu's container tag. The following special options are
273
     * recognized:
274
     *
275
     * - tag: string, defaults to "ul", the tag name of the item container tags. Set to false to disable container tag.
276
     *   See also {@see \Yiisoft\Html\Html::tag()}.
277
     *
278
     * @return $this
279
     *
280
     * {@see \Yiisoft\Html\Html::renderTagAttributes()} for details on how attributes are being rendered.
281
     */
282 1
    public function options(array $value): self
283
    {
284 1
        $this->options = $value;
285
286 1
        return $this;
287
    }
288
289
    /**
290
     * Recursively renders the menu items (without the container tag).
291
     *
292
     * @param array $items the menu items to be rendered recursively
293
     *
294
     * @throws JsonException
295
     *
296
     * @return string the rendering result
297
     */
298 7
    protected function renderItems(array $items): string
299
    {
300 7
        $n = count($items);
301 7
        $lines = [];
302
303 7
        foreach ($items as $i => $item) {
304 7
            $options = array_merge($this->itemOptions, ArrayHelper::getValue($item, 'options', []));
305 7
            $tag = ArrayHelper::remove($options, 'tag', 'li');
306 7
            $class = [];
307
308 7
            if ($item['active']) {
309 3
                $class[] = $this->activeCssClass;
310
            }
311
312 7
            if ($i === 0 && $this->firstItemCssClass !== null) {
313
                $class[] = $this->firstItemCssClass;
314
            }
315
316 7
            if ($i === $n - 1 && $this->lastItemCssClass !== null) {
317
                $class[] = $this->lastItemCssClass;
318
            }
319
320 7
            Html::addCssClass($options, $class);
321
322 7
            $menu = $this->renderItem($item);
0 ignored issues
show
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

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