├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── composer.json
├── psalm80.xml
├── rector.php
└── src
├── Alert.php
├── Block.php
├── Breadcrumbs.php
├── ContentDecorator.php
├── Dropdown.php
├── FragmentCache.php
├── Helper
└── Normalizer.php
└── Menu.php
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Yii Widgets Change Log
2 |
3 | ## 2.1.1 under development
4 |
5 | - Chg #86, #95: Raise required `yiisoft/view` version to `10 - 12` (@vjik, @Tigrov)
6 | - Bug #97: Fix `Block` behavior when content is "0" (@vjik)
7 |
8 | ## 2.1.0 February 19, 2023
9 |
10 | - Chg: #71: Add support of `yiisoft/cache` version `^3.0` (@vjik)
11 | - Chg: #71: Update `yiisoft/aliases` version to `^3.0` and `yiisoft/view` version to `^8.0` (@rustamwin)
12 |
13 | ## 2.0.0 January 27, 2023
14 |
15 | - Chg #67: Upgrade `yiisoft/widget` version to `^2.0` (@rustamwin)
16 |
17 | ## 1.1.0 December 21, 2022
18 |
19 | - Enh #59: Fix phpdocs and check type for array normalized in `Dropdown::class`, `Menu::class` (@terabytesoftw)
20 | - Enh #64: Add supports of `yiisoft/html` version `^3.0` and `yiisoft/view` version `^7.0` (@vjik)
21 |
22 | ## 1.0.0 October 12, 2022
23 |
24 | - Initial release.
25 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright © 2008 by Yii Software ()
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions
6 | are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in
12 | the documentation and/or other materials provided with the
13 | distribution.
14 | * Neither the name of Yii Software nor the names of its
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 | POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Yii Widgets
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/yii-widgets)
10 | [](https://packagist.org/packages/yiisoft/yii-widgets)
11 | [](https://github.com/yiisoft/yii-widgets/actions?query=workflow%3Abuild)
12 | [](https://codecov.io/gh/yiisoft/yii-widgets)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-widgets/master)
14 | [](https://github.com/yiisoft/yii-widgets/actions?query=workflow%3A%22static+analysis%22)
15 | [](https://shepherd.dev/github/yiisoft/yii-widgets)
16 |
17 | Collection of useful widgets for [Yii Framework](https://www.yiiframework.com/).
18 |
19 | ## Requirements
20 |
21 | - PHP 8.0 or higher.
22 |
23 | ### Installation
24 |
25 | The package could be installed with [Composer](https://getcomposer.org):
26 |
27 | ```shell
28 | composer require yiisoft/yii-widgets
29 | ```
30 |
31 | ## General usage
32 |
33 | All widgets extend the abstract `Yiisoft\Widget\Widget` class from the
34 | [yiisoft/widget](https://github.com/yiisoft/widget) package.
35 |
36 | ## Documentation
37 |
38 | - Guide: [English](docs/guide/en/README.md), [Português - Brasil](docs/guide/pt-BR/README.md)
39 | - [Internals](docs/internals.md)
40 |
41 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that.
42 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community).
43 |
44 | ## License
45 |
46 | The Yii Widgets is free software. It is released under the terms of the BSD License.
47 | Please see [`LICENSE`](./LICENSE.md) for more information.
48 |
49 | Maintained by [Yii Software](https://www.yiiframework.com/).
50 |
51 | ## Support the project
52 |
53 | [](https://opencollective.com/yiisoft)
54 |
55 | ## Follow updates
56 |
57 | [](https://www.yiiframework.com/)
58 | [](https://twitter.com/yiiframework)
59 | [](https://t.me/yii3en)
60 | [](https://www.facebook.com/groups/yiitalk)
61 | [](https://yiiframework.com/go/slack)
62 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yiisoft/yii-widgets",
3 | "type": "library",
4 | "description": "Yii widgets collection",
5 | "keywords": [
6 | "yii",
7 | "widgets"
8 | ],
9 | "homepage": "https://www.yiiframework.com/",
10 | "license": "BSD-3-Clause",
11 | "support": {
12 | "issues": "https://github.com/yiisoft/yii-widgets/issues?state=open",
13 | "source": "https://github.com/yiisoft/yii-widgets",
14 | "forum": "https://www.yiiframework.com/forum/",
15 | "wiki": "https://www.yiiframework.com/wiki/",
16 | "irc": "ircs://irc.libera.chat:6697/yii",
17 | "chat": "https://t.me/yii3en"
18 | },
19 | "funding": [
20 | {
21 | "type": "opencollective",
22 | "url": "https://opencollective.com/yiisoft"
23 | },
24 | {
25 | "type": "github",
26 | "url": "https://github.com/sponsors/yiisoft"
27 | }
28 | ],
29 | "require": {
30 | "php": "^8.0",
31 | "yiisoft/aliases": "^3.0",
32 | "yiisoft/cache": "^2.0|^3.0",
33 | "yiisoft/html": "^2.5|^3.0",
34 | "yiisoft/view": "10 - 12",
35 | "yiisoft/widget": "^2.0"
36 | },
37 | "require-dev": {
38 | "maglnet/composer-require-checker": "^4.4",
39 | "phpunit/phpunit": "^9.6.23",
40 | "rector/rector": "^2.0.17",
41 | "roave/infection-static-analysis-plugin": "^1.25",
42 | "spatie/phpunit-watcher": "^1.23.6",
43 | "vimeo/psalm": "^4.30|^5.26.1|^6.12",
44 | "yiisoft/psr-dummy-provider": "^1.0.2",
45 | "yiisoft/test-support": "^3.0.2"
46 | },
47 | "autoload": {
48 | "psr-4": {
49 | "Yiisoft\\Yii\\Widgets\\": "src"
50 | }
51 | },
52 | "autoload-dev": {
53 | "psr-4": {
54 | "Yiisoft\\Yii\\Widgets\\Tests\\": "tests"
55 | }
56 | },
57 | "scripts": {
58 | "test": "phpunit --testdox --no-interaction",
59 | "test-watch": "phpunit-watcher watch"
60 | },
61 | "config": {
62 | "sort-packages": true,
63 | "allow-plugins": {
64 | "infection/extension-installer": true,
65 | "composer/package-versions-deprecated": true
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/psalm80.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | withPaths([
10 | __DIR__ . '/src',
11 | __DIR__ . '/tests',
12 | ])
13 | ->withPhpSets(php80: true)
14 | ->withRules([
15 | InlineConstructorDefaultToPropertyRector::class,
16 | ]);
17 |
--------------------------------------------------------------------------------
/src/Alert.php:
--------------------------------------------------------------------------------
1 | attributes = $valuesMap;
57 |
58 | return $new;
59 | }
60 |
61 | /**
62 | * Returns a new instance with changed message body.
63 | *
64 | * @param string $value The message body.
65 | */
66 | public function body(string $value): self
67 | {
68 | $new = clone $this;
69 | $new->body = $value;
70 |
71 | return $new;
72 | }
73 |
74 | /**
75 | * Returns a new instance with the HTML attributes for the message body tag.
76 | *
77 | * @param array $valuesMap Attribute values indexed by attribute names.
78 | *
79 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
80 | */
81 | public function bodyAttributes(array $valuesMap): self
82 | {
83 | $new = clone $this;
84 | $new->bodyAttributes = $valuesMap;
85 |
86 | return $new;
87 | }
88 |
89 | /**
90 | * Returns a new instance with CSS class for the message body tag.
91 | *
92 | * @param string $value The CSS class name.
93 | */
94 | public function bodyClass(string $value): self
95 | {
96 | $new = clone $this;
97 | Html::addCssClass($new->bodyAttributes, $value);
98 |
99 | return $new;
100 | }
101 |
102 | /**
103 | * Returns a new instance specifying when allows you to add an extra wrapper for the panel body.
104 | *
105 | * @param bool $value Whether to add an extra wrapper for the panel body.
106 | */
107 | public function bodyContainer(bool $value): self
108 | {
109 | $new = clone $this;
110 | $new->bodyContainer = $value;
111 |
112 | return $new;
113 | }
114 |
115 | /**
116 | * Returns a new instance with the HTML attributes for rendering extra message wrapper.
117 | *
118 | * @param array $valuesMap Attribute values indexed by attribute names.
119 | *
120 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
121 | */
122 | public function bodyContainerAttributes(array $valuesMap): self
123 | {
124 | $new = clone $this;
125 | $new->bodyContainerAttributes = $valuesMap;
126 |
127 | return $new;
128 | }
129 |
130 | /**
131 | * Returns a new instance with the CSS class for extra message wrapper.
132 | *
133 | * @param string $value The CSS class name.
134 | */
135 | public function bodyContainerClass(string $value): self
136 | {
137 | $new = clone $this;
138 | Html::addCssClass($new->bodyContainerAttributes, $value);
139 |
140 | return $new;
141 | }
142 |
143 | /**
144 | * Returns a new instance specifying when allows you to add an extra wrapper for the message body.
145 | *
146 | * @param string|null $tag The tag name.
147 | */
148 | public function bodyTag(?string $tag = null): self
149 | {
150 | if ($tag === '') {
151 | throw new InvalidArgumentException('Body tag must be a string and cannot be empty.');
152 | }
153 |
154 | $new = clone $this;
155 | $new->bodyTag = $tag;
156 |
157 | return $new;
158 | }
159 |
160 | /**
161 | * Returns a new instance with the HTML the attributes for rendering the button tag.
162 | *
163 | * The button is displayed in the header of the modal window. Clicking on the button will hide the modal.
164 | *
165 | * If {@see buttonEnabled} is `false`, no button will be rendered.
166 | *
167 | * The rest of the options will be rendered as the HTML attributes of the button tag.
168 | *
169 | * @param array $valuesMap Attribute values indexed by attribute names.
170 | *
171 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
172 | */
173 | public function buttonAttributes(array $valuesMap): self
174 | {
175 | $new = clone $this;
176 | $new->buttonAttributes = $valuesMap;
177 |
178 | return $new;
179 | }
180 |
181 | /**
182 | * Returns a new instance with the CSS class for the button.
183 | *
184 | * @param string $value The CSS class name.
185 | */
186 | public function buttonClass(string $value): self
187 | {
188 | $new = clone $this;
189 | Html::addCssClass($new->buttonAttributes, $value);
190 |
191 | return $new;
192 | }
193 |
194 | /**
195 | * Returns a new instance with the label for the button.
196 | *
197 | * @param string $value The label for the button.
198 | */
199 | public function buttonLabel(string $value = ''): self
200 | {
201 | $new = clone $this;
202 | $new->buttonLabel = $value;
203 |
204 | return $new;
205 | }
206 |
207 | /**
208 | * Returns a new instance with the `onclick` JavaScript for the button.
209 | *
210 | * @param string $value The `onclick` JavaScript for the button.
211 | */
212 | public function buttonOnClick(string $value): self
213 | {
214 | $new = clone $this;
215 | $new->buttonAttributes['onclick'] = $value;
216 |
217 | return $new;
218 | }
219 |
220 | /**
221 | * Returns a new instance with the CSS class for the widget.
222 | *
223 | * @param string $value The CSS class name.
224 | */
225 | public function class(string $value): self
226 | {
227 | $new = clone $this;
228 | Html::addCssClass($new->attributes, $value);
229 |
230 | return $new;
231 | }
232 |
233 | /**
234 | * Returns a new instance with the header content.
235 | *
236 | * @param string $value The header content in the message.
237 | */
238 | public function header(string $value): self
239 | {
240 | $new = clone $this;
241 | $new->header = $value;
242 |
243 | return $new;
244 | }
245 |
246 | /**
247 | * Returns a new instance with the HTML attributes for rendering the header content.
248 | *
249 | * @param array $valuesMap Attribute values indexed by attribute names.
250 | *
251 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
252 | */
253 | public function headerAttributes(array $valuesMap): self
254 | {
255 | $new = clone $this;
256 | $new->headerAttributes = $valuesMap;
257 |
258 | return $new;
259 | }
260 |
261 | /**
262 | * Returns a new instance with the CSS class for the header.
263 | *
264 | * @param string $value The CSS class name.
265 | */
266 | public function headerClass(string $value): self
267 | {
268 | $new = clone $this;
269 | Html::addCssClass($new->headerAttributes, $value);
270 |
271 | return $new;
272 | }
273 |
274 | /**
275 | * Returns a new instance specifying when allows you to add a div tag to the header extra wrapper.
276 | *
277 | * @param bool $value The value indicating whether to add a div tag to the header extra wrapper.
278 | */
279 | public function headerContainer(bool $value = true): self
280 | {
281 | $new = clone $this;
282 | $new->headerContainer = $value;
283 |
284 | return $new;
285 | }
286 |
287 | /**
288 | * Returns a new instance with the HTML attributes for rendering the header.
289 | *
290 | * @param array $valuesMap Attribute values indexed by attribute names.
291 | *
292 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
293 | */
294 | public function headerContainerAttributes(array $valuesMap): self
295 | {
296 | $new = clone $this;
297 | $new->headerContainerAttributes = $valuesMap;
298 |
299 | return $new;
300 | }
301 |
302 | /**
303 | * Returns a new instance with the CSS class for the header extra wrapper.
304 | *
305 | * @param string $value The CSS class name.
306 | */
307 | public function headerContainerClass(string $value): self
308 | {
309 | $new = clone $this;
310 | Html::addCssClass($new->headerContainerAttributes, $value);
311 |
312 | return $new;
313 | }
314 |
315 | /**
316 | * Returns a new instance with the tag name for the header.
317 | *
318 | * @param string $value The tag name for the header.
319 | *
320 | * @throws InvalidArgumentException
321 | */
322 | public function headerTag(string $value): self
323 | {
324 | if (empty($value)) {
325 | throw new InvalidArgumentException('Header tag must be a string and cannot be empty.');
326 | }
327 |
328 | $new = clone $this;
329 | $new->headerTag = $value;
330 |
331 | return $new;
332 | }
333 |
334 | /**
335 | * Returns a new instance with the HTML attributes for rendering the `` tag for the icon.
336 | *
337 | * @param array $valuesMap Attribute values indexed by attribute names.
338 | *
339 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
340 | */
341 | public function iconAttributes(array $valuesMap): self
342 | {
343 | $new = clone $this;
344 | $new->iconAttributes = $valuesMap;
345 |
346 | return $new;
347 | }
348 |
349 | /**
350 | * Returns a new instance with the icon CSS class.
351 | *
352 | * @param string $value The icon CSS class.
353 | */
354 | public function iconClass(string $value): self
355 | {
356 | $new = clone $this;
357 | Html::addCssClass($new->iconAttributes, $value);
358 |
359 | return $new;
360 | }
361 |
362 | /**
363 | * Returns a new instance with the HTML attributes for rendering icon container.
364 | *
365 | * The rest of the options will be rendered as the HTML attributes of the icon container.
366 | *
367 | * @param array $valuesMap Attribute values indexed by attribute names.
368 | *
369 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
370 | */
371 | public function iconContainerAttributes(array $valuesMap): self
372 | {
373 | $new = clone $this;
374 | $new->iconContainerAttributes = $valuesMap;
375 |
376 | return $new;
377 | }
378 |
379 | /**
380 | * Returns a new instance with the CSS class for the icon container.
381 | *
382 | * @param string $value The CSS class name.
383 | */
384 | public function iconContainerClass(string $value): self
385 | {
386 | $new = clone $this;
387 | Html::addCssClass($new->iconContainerAttributes, $value);
388 |
389 | return $new;
390 | }
391 |
392 | /**
393 | * Returns a new instance with the icon text.
394 | *
395 | * @param string $value The icon text.
396 | */
397 | public function iconText(string $value): self
398 | {
399 | $new = clone $this;
400 | $new->iconText = $value;
401 |
402 | return $new;
403 | }
404 |
405 | /**
406 | * Returns a new instance with the specified Widget ID.
407 | *
408 | * @param string $value The id of the widget.
409 | */
410 | public function id(string $value): self
411 | {
412 | $new = clone $this;
413 | $new->attributes['id'] = $value;
414 |
415 | return $new;
416 | }
417 |
418 | /**
419 | * Returns a new instance with the config layout body.
420 | *
421 | * @param string $value The config layout body.
422 | */
423 | public function layoutBody(string $value): self
424 | {
425 | $new = clone $this;
426 | $new->layoutBody = $value;
427 |
428 | return $new;
429 | }
430 |
431 | /**
432 | * Returns a new instance with the config layout header.
433 | *
434 | * @param string $value The config layout header.
435 | */
436 | public function layoutHeader(string $value): self
437 | {
438 | $new = clone $this;
439 | $new->layoutHeader = $value;
440 |
441 | return $new;
442 | }
443 |
444 | public function render(): string
445 | {
446 | $div = Div::tag();
447 | $parts = [];
448 |
449 | if (!array_key_exists('id', $this->attributes)) {
450 | $div = $div->id(Html::generateId('alert-'));
451 | }
452 |
453 | $parts['{button}'] = $this->renderButton();
454 | $parts['{icon}'] = $this->renderIcon();
455 | $parts['{body}'] = $this->renderBody();
456 | $parts['{header}'] = $this->renderHeader();
457 |
458 | $contentAlert = $this->renderHeaderContainer($parts) . PHP_EOL . $this->renderBodyContainer($parts);
459 |
460 | return $this->body !== ''
461 | ? $div
462 | ->attribute('role', 'alert')
463 | ->addAttributes($this->attributes)
464 | ->content(PHP_EOL . trim($contentAlert) . PHP_EOL)
465 | ->encode(false)
466 | ->render()
467 | : '';
468 | }
469 |
470 | /**
471 | * Renders close button.
472 | */
473 | private function renderButton(): string
474 | {
475 | return PHP_EOL .
476 | Button::tag()
477 | ->attributes($this->buttonAttributes)
478 | ->content($this->buttonLabel)
479 | ->encode(false)
480 | ->type('button')
481 | ->render();
482 | }
483 |
484 | /**
485 | * Render icon.
486 | */
487 | private function renderIcon(): string
488 | {
489 | return PHP_EOL .
490 | Div::tag()
491 | ->attributes($this->iconContainerAttributes)
492 | ->content(I::tag()->attributes($this->iconAttributes)->content($this->iconText)->render())
493 | ->encode(false)
494 | ->render() .
495 | PHP_EOL;
496 | }
497 |
498 | /**
499 | * Render the alert message body.
500 | */
501 | private function renderBody(): string
502 | {
503 | return $this->bodyTag !== null
504 | ? Html::normalTag($this->bodyTag, $this->body, $this->bodyAttributes)->encode(false)->render()
505 | : $this->body;
506 | }
507 |
508 | /**
509 | * Render the header.
510 | */
511 | private function renderHeader(): string
512 | {
513 | return Html::normalTag($this->headerTag, $this->header, $this->headerAttributes)->encode(false)->render();
514 | }
515 |
516 | /**
517 | * Render the header container.
518 | */
519 | private function renderHeaderContainer(array $parts): string
520 | {
521 | $headerHtml = trim(strtr($this->layoutHeader, $parts));
522 |
523 | return $this->headerContainer && $headerHtml !== ''
524 | ? Div::tag()
525 | ->attributes($this->headerContainerAttributes)
526 | ->content(PHP_EOL . $headerHtml . PHP_EOL)
527 | ->encode(false)
528 | ->render()
529 | : $headerHtml;
530 | }
531 |
532 | /**
533 | * Render the panel body.
534 | */
535 | private function renderBodyContainer(array $parts): string
536 | {
537 | $bodyHtml = trim(strtr($this->layoutBody, $parts));
538 |
539 | return $this->bodyContainer
540 | ? Div::tag()
541 | ->attributes($this->bodyContainerAttributes)
542 | ->content(PHP_EOL . $bodyHtml . PHP_EOL)
543 | ->encode(false)
544 | ->render()
545 | : $bodyHtml;
546 | }
547 | }
548 |
--------------------------------------------------------------------------------
/src/Block.php:
--------------------------------------------------------------------------------
1 | id('my-block')
23 | * ->begin() ?>
24 | * Nothing.
25 | *
26 | * ```
27 | *
28 | * And then overriding default in views:
29 | *
30 | * ```php
31 | * id('my-block')
33 | * ->begin() ?>
34 | * Umm... hello?
35 | *
36 | * ```
37 | *
38 | * in subviews show block:
39 | *
40 | * ```php
41 | * = $this->getBlock('my-block') ?>
42 | * ```
43 | */
44 | final class Block extends Widget
45 | {
46 | private string $id = '';
47 | private bool $renderInPlace = false;
48 |
49 | public function __construct(private WebView $webView)
50 | {
51 | }
52 |
53 | /**
54 | * Starts recording a block.
55 | */
56 | public function begin(): string|null
57 | {
58 | parent::begin();
59 |
60 | ob_start();
61 |
62 | return null;
63 | }
64 |
65 | /**
66 | * Returns a new instance with the specified Widget ID.
67 | *
68 | * @param string $value The Widget ID.
69 | */
70 | public function id(string $value): self
71 | {
72 | $new = clone $this;
73 | $new->id = $value;
74 |
75 | return $new;
76 | }
77 |
78 | /**
79 | * Enables in-place rendering and returns a new instance.
80 | *
81 | * Without calling this method, the captured content of the block is not displayed.
82 | */
83 | public function renderInPlace(): self
84 | {
85 | $new = clone $this;
86 | $new->renderInPlace = true;
87 |
88 | return $new;
89 | }
90 |
91 | /**
92 | * Ends recording a block.
93 | *
94 | * This method stops output buffering and saves the rendering result as a named block in the view.
95 | *
96 | * @return string The result of widget execution to be outputted.
97 | */
98 | public function render(): string
99 | {
100 | if ($this->id === '') {
101 | ob_end_clean();
102 | throw new RuntimeException('You must assign the "id" using the "id()" setter.');
103 | }
104 |
105 | $block = ob_get_clean();
106 | if ($block === false || $block === '') {
107 | return '';
108 | }
109 |
110 | if ($this->renderInPlace) {
111 | return $block;
112 | }
113 |
114 | $this->webView->setBlock($this->id, $block);
115 | return '';
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/Breadcrumbs.php:
--------------------------------------------------------------------------------
1 | itemTemplate() => "{link}\n", // template for all links
30 | * -> items() => [
31 | * [
32 | * 'label' => 'Post Category',
33 | * 'url' => 'post-category/view?id=10',
34 | * 'template' => "{link}\n", // template for this link only
35 | * ],
36 | * ['label' => 'Sample Post', 'url' => 'post/edit?id=1'],
37 | * 'Edit',
38 | * ];
39 | * ```
40 | *
41 | * Because breadcrumbs usually appears in nearly every page of a website, you may consider placing it in a layout view.
42 | * You can use a view common parameter (e.g. `$this->getCommonParameter('breadcrumbs')`) to configure the items in
43 | * different views. In the layout view, you assign this view parameter to the {@see Breadcrumbs::items()} method
44 | * like the following:
45 | *
46 | * ```php
47 | * // $this is the view object currently being used
48 | * echo Breadcrumbs::widget()->items($this->getCommonParameter('breadcrumbs', []));
49 | * ```
50 | */
51 | final class Breadcrumbs extends Widget
52 | {
53 | private string $activeItemTemplate = "{link}\n";
54 | private array $attributes = ['class' => 'breadcrumb'];
55 | private array|null $homeItem = ['label' => 'Home', 'url' => '/'];
56 | private array $items = [];
57 | private string $itemTemplate = "{link}\n";
58 | private string $tag = 'ul';
59 |
60 | /**
61 | * Returns a new instance with the specified active item template.
62 | *
63 | * @param string $value The template used to render each active item in the breadcrumbs.
64 | * The token `{link}` will be replaced with the actual HTML link for each active item.
65 | */
66 | public function activeItemTemplate(string $value): self
67 | {
68 | $new = clone $this;
69 | $new->activeItemTemplate = $value;
70 |
71 | return $new;
72 | }
73 |
74 | /**
75 | * Returns a new instance with the HTML attributes. The following special options are recognized.
76 | *
77 | * @param array $valuesMap Attribute values indexed by attribute names.
78 | */
79 | public function attributes(array $valuesMap): self
80 | {
81 | $new = clone $this;
82 | $new->attributes = $valuesMap;
83 |
84 | return $new;
85 | }
86 |
87 | /**
88 | * Returns a new instance with the specified first item in the breadcrumbs (called home link).
89 | *
90 | * If a null is specified, the home item will not be rendered.
91 | *
92 | * @param array|null $value Please refer to {@see items()} on the format.
93 | *
94 | * @throws InvalidArgumentException If an empty array is specified.
95 | */
96 | public function homeItem(?array $value): self
97 | {
98 | if ($value === []) {
99 | throw new InvalidArgumentException(
100 | 'The home item cannot be an empty array. To disable rendering of the home item, specify null.',
101 | );
102 | }
103 |
104 | $new = clone $this;
105 | $new->homeItem = $value;
106 |
107 | return $new;
108 | }
109 |
110 | /**
111 | * Returns a new instance with the specified list of items.
112 | *
113 | * @param array $value List of items to appear in the breadcrumbs. If this property is empty, the widget will not
114 | * render anything. Each array element represents a single item in the breadcrumbs with the following structure:
115 | *
116 | * ```php
117 | * [
118 | * 'label' => 'label of the item', // required
119 | * 'url' => 'url of the item', // optional
120 | * 'template' => 'own template of the item', // optional, if not set $this->itemTemplate will be used
121 | * ]
122 | * ```
123 | *
124 | * If an item is active, you only need to specify its "label", and instead of writing `['label' => $label]`, you may
125 | * simply use `$label`.
126 | *
127 | * Additional array elements for each item will be treated as the HTML attributes for the hyperlink tag.
128 | * For example, the following item specification will generate a hyperlink with CSS class `external`:
129 | *
130 | * ```php
131 | * [
132 | * 'label' => 'demo',
133 | * 'url' => 'http://example.com',
134 | * 'class' => 'external',
135 | * ]
136 | * ```
137 | *
138 | * To disable encode for a specific item, you can set the encode option to false:
139 | *
140 | * ```php
141 | * [
142 | * 'label' => 'Hello!',
143 | * 'encode' => false,
144 | * ]
145 | * ```
146 | */
147 | public function items(array $value): self
148 | {
149 | $new = clone $this;
150 | $new->items = $value;
151 |
152 | return $new;
153 | }
154 |
155 | /**
156 | * Returns a new instance with the specified item template.
157 | *
158 | * @param string $value The template used to render each inactive item in the breadcrumbs.
159 | * The token `{link}` will be replaced with the actual HTML link for each inactive item.
160 | */
161 | public function itemTemplate(string $value): self
162 | {
163 | $new = clone $this;
164 | $new->itemTemplate = $value;
165 |
166 | return $new;
167 | }
168 |
169 | /**
170 | * Returns a new instance with the specified tag.
171 | *
172 | * @param string $value The tag name.
173 | */
174 | public function tag(string $value): self
175 | {
176 | $new = clone $this;
177 | $new->tag = $value;
178 |
179 | return $new;
180 | }
181 |
182 | /**
183 | * Renders the widget.
184 | *
185 | * @return string The result of widget execution to be outputted.
186 | */
187 | public function render(): string
188 | {
189 | if ($this->items === []) {
190 | return '';
191 | }
192 |
193 | $items = [];
194 |
195 | if ($this->homeItem !== null) {
196 | $items[] = $this->renderItem($this->homeItem, $this->itemTemplate);
197 | }
198 |
199 | foreach ($this->items as $item) {
200 | if (!is_array($item)) {
201 | $item = ['label' => $item];
202 | }
203 |
204 | if ($item !== []) {
205 | $items[] = $this->renderItem(
206 | $item,
207 | isset($item['url']) ? $this->itemTemplate : $this->activeItemTemplate
208 | );
209 | }
210 | }
211 |
212 | $body = implode('', $items);
213 |
214 | return empty($this->tag)
215 | ? $body
216 | : Html::normalTag($this->tag, PHP_EOL . $body, $this->attributes)->encode(false)->render();
217 | }
218 |
219 | /**
220 | * Renders a single breadcrumb item.
221 | *
222 | * @param array $item The item to be rendered. It must contain the "label" element. The "url" element is optional.
223 | * @param string $template The template to be used to render the link. The token "{link}" will be replaced by the
224 | * link.
225 | *
226 | * @throws InvalidArgumentException if `$item` does not have "label" element.
227 | *
228 | * @return string The rendering result.
229 | */
230 | private function renderItem(array $item, string $template): string
231 | {
232 | if (!array_key_exists('label', $item)) {
233 | throw new InvalidArgumentException('The "label" element is required for each item.');
234 | }
235 |
236 | if (!is_string($item['label'])) {
237 | throw new InvalidArgumentException('The "label" element must be a string.');
238 | }
239 |
240 | /** @var bool $encodeLabel */
241 | $encodeLabel = $item['encode'] ?? true;
242 | $label = $encodeLabel ? Html::encode($item['label']) : $item['label'];
243 |
244 | if (isset($item['template']) && is_string($item['template'])) {
245 | $template = $item['template'];
246 | }
247 |
248 | if (isset($item['url']) && is_string($item['url'])) {
249 | $link = $item['url'];
250 | unset($item['template'], $item['label'], $item['url']);
251 | $link = Html::a($label, $link, $item);
252 | } else {
253 | $link = $label;
254 | }
255 |
256 | return strtr($template, ['{link}' => $link]);
257 | }
258 | }
259 |
--------------------------------------------------------------------------------
/src/ContentDecorator.php:
--------------------------------------------------------------------------------
1 | viewFile('@app/views/layouts/base.php')
23 | * ->parameters([])
24 | * ->begin();
25 | * ?>
26 | *
27 | * some content here
28 | *
29 | * = ContentDecorator::end() ?>
30 | * ```
31 | */
32 | final class ContentDecorator extends Widget
33 | {
34 | private array $parameters = [];
35 | private string $viewFile = '';
36 |
37 | public function __construct(private Aliases $aliases, private WebView $webView)
38 | {
39 | }
40 |
41 | /**
42 | * Returns a new instance with the specified parameters.
43 | *
44 | * @param array $value The parameters (name => value) to be extracted and made available in the decorative view.
45 | */
46 | public function parameters(array $value): self
47 | {
48 | $new = clone $this;
49 | $new->parameters = $value;
50 |
51 | return $new;
52 | }
53 |
54 | /**
55 | * Returns a new instance with the specified view file.
56 | *
57 | * @param string $value The view file that will be used to decorate the content enclosed by this widget.
58 | * This can be specified as either the view file path or alias path.
59 | */
60 | public function viewFile(string $value): self
61 | {
62 | $new = clone $this;
63 | $new->viewFile = $this->aliases->get($value);
64 |
65 | return $new;
66 | }
67 |
68 | /**
69 | * Starts recording a content.
70 | */
71 | public function begin(): ?string
72 | {
73 | parent::begin();
74 |
75 | ob_start();
76 |
77 | return null;
78 | }
79 |
80 | /**
81 | * Ends recording a content.
82 | *
83 | * This method stops output buffering and saves the rendering result as a `$content`
84 | * variable and then echoes rendering result.
85 | *
86 | * @throws Throwable|ViewNotFoundException
87 | *
88 | * @return string The result of widget execution to be outputted.
89 | */
90 | public function render(): string
91 | {
92 | $parameters = $this->parameters;
93 | $parameters['content'] = ob_get_clean();
94 |
95 | /** render under the existing context */
96 | return $this->webView->render($this->viewFile, $parameters);
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/Dropdown.php:
--------------------------------------------------------------------------------
1 | activeClass = $value;
60 |
61 | return $new;
62 | }
63 |
64 | /**
65 | * Returns a new instance with the specified if the container is enabled, or not. Default is true.
66 | *
67 | * @param bool $value The container enabled.
68 | */
69 | public function container(bool $value): self
70 | {
71 | $new = clone $this;
72 | $new->container = $value;
73 |
74 | return $new;
75 | }
76 |
77 | /**
78 | * Returns a new instance with the specified container HTML attributes.
79 | *
80 | * @param array $valuesMap Attribute values indexed by attribute names.
81 | */
82 | public function containerAttributes(array $valuesMap): self
83 | {
84 | $new = clone $this;
85 | $new->containerAttributes = $valuesMap;
86 |
87 | return $new;
88 | }
89 |
90 | /**
91 | * Returns a new instance with the specified container class.
92 | *
93 | * @param string $value The container class.
94 | */
95 | public function containerClass(string $value): self
96 | {
97 | $new = clone $this;
98 | $new->containerClass = $value;
99 |
100 | return $new;
101 | }
102 |
103 | /**
104 | * Returns a new instance with the specified container tag.
105 | *
106 | * @param string $value The container tag.
107 | */
108 | public function containerTag(string $value): self
109 | {
110 | $new = clone $this;
111 | $new->containerTag = $value;
112 | return $new;
113 | }
114 |
115 | /**
116 | * Returns a new instance with the specified disabled class.
117 | *
118 | * @param string $value The disabled class.
119 | */
120 | public function disabledClass(string $value): self
121 | {
122 | $new = clone $this;
123 | $new->disabledClass = $value;
124 |
125 | return $new;
126 | }
127 |
128 | /**
129 | * Returns a new instance with the specified divider HTML attributes.
130 | *
131 | * @param array $valuesMap Attribute values indexed by attribute names.
132 | */
133 | public function dividerAttributes(array $valuesMap): self
134 | {
135 | $new = clone $this;
136 | $new->dividerAttributes = $valuesMap;
137 |
138 | return $new;
139 | }
140 |
141 | /**
142 | * Returns a new instance with the specified divider class.
143 | *
144 | * @param string $value The divider class.
145 | */
146 | public function dividerClass(string $value): self
147 | {
148 | $new = clone $this;
149 | $new->dividerClass = $value;
150 |
151 | return $new;
152 | }
153 |
154 | /**
155 | * Returns a new instance with the specified divider tag.
156 | *
157 | * @param string $value The divider tag.
158 | */
159 | public function dividerTag(string $value): self
160 | {
161 | $new = clone $this;
162 | $new->dividerTag = $value;
163 |
164 | return $new;
165 | }
166 |
167 | /**
168 | * Returns a new instance with the specified header class.
169 | *
170 | * @param string $value The header class.
171 | */
172 | public function headerClass(string $value): self
173 | {
174 | $new = clone $this;
175 | $new->headerClass = $value;
176 |
177 | return $new;
178 | }
179 |
180 | /**
181 | * Returns a new instance with the specified header tag.
182 | *
183 | * @param string $value The header tag.
184 | */
185 | public function headerTag(string $value): self
186 | {
187 | $new = clone $this;
188 | $new->headerTag = $value;
189 |
190 | return $new;
191 | }
192 |
193 | /**
194 | * Returns a new instance with the specified Widget ID.
195 | *
196 | * @param string $value The id of the widget.
197 | */
198 | public function id(string $value): self
199 | {
200 | $new = clone $this;
201 | $new->id = $value;
202 |
203 | return $new;
204 | }
205 |
206 | /**
207 | * Returns a new instance with the specified item class.
208 | *
209 | * @param string $value The item class.
210 | */
211 | public function itemClass(string $value): self
212 | {
213 | $new = clone $this;
214 | $new->itemClass = $value;
215 |
216 | return $new;
217 | }
218 |
219 | /**
220 | * Returns a new instance with the specified item container, if false, the item container will not be rendered.
221 | *
222 | * @param bool $value The item container.
223 | */
224 | public function itemContainer(bool $value): self
225 | {
226 | $new = clone $this;
227 | $new->itemContainer = $value;
228 |
229 | return $new;
230 | }
231 |
232 | /**
233 | * Returns a new instance with the specified item container HTML attributes.
234 | *
235 | * @param array $valuesMap Attribute values indexed by attribute names.
236 | */
237 | public function itemContainerAttributes(array $valuesMap): self
238 | {
239 | $new = clone $this;
240 | $new->itemContainerAttributes = $valuesMap;
241 |
242 | return $new;
243 | }
244 |
245 | /**
246 | * Returns a new instance with the specified item container class.
247 | *
248 | * @param string $value The item container class.
249 | */
250 | public function itemContainerClass(string $value): self
251 | {
252 | $new = clone $this;
253 | Html::addCssClass($new->itemContainerAttributes, $value);
254 |
255 | return $new;
256 | }
257 |
258 | /**
259 | * Returns a new instance with the specified item container tag.
260 | *
261 | * @param string $value The item container tag.
262 | */
263 | public function itemContainerTag(string $value): self
264 | {
265 | $new = clone $this;
266 | $new->itemContainerTag = $value;
267 |
268 | return $new;
269 | }
270 |
271 | /**
272 | * Returns a new instance with the specified item tag.
273 | *
274 | * @param string $value The item tag.
275 | */
276 | public function itemTag(string $value): self
277 | {
278 | $new = clone $this;
279 | $new->itemTag = $value;
280 |
281 | return $new;
282 | }
283 |
284 | /**
285 | * List of menu items in the dropdown. Each array element can be either an HTML string, or an array representing a
286 | * single menu with the following structure:
287 | *
288 | * - label: string, required, the nav item label.
289 | * - active: bool, whether the item should be on active state or not.
290 | * - disabled: bool, whether the item should be on disabled state or not. For default `disabled` is false.
291 | * - enclose: bool, whether the item should be enclosed by a `` tag or not. For default `enclose` is true.
292 | * - encode: bool, whether the label should be HTML encoded or not. For default `encodeLabel` is true.
293 | * - headerAttributes: array, HTML attributes to be rendered in the item header.
294 | * - link: string, the item's href. Defaults to "#". For default `link` is "#".
295 | * - linkAttributes: array, the HTML attributes of the item's link. For default `linkAttributes` is `[]`.
296 | * - icon: string, the item's icon. For default `icon` is ``.
297 | * - iconAttributes: array, the HTML attributes of the item's icon. For default `iconAttributes` is `[]`.
298 | * - visible: bool, optional, whether this menu item is visible. Defaults to true.
299 | * - items: array, optional, the submenu items. The structure is the same as this property.
300 | * Note that Bootstrap doesn't support dropdown submenu. You have to add your own CSS styles to support it.
301 | * - itemsContainerAttributes: array, optional, the HTML attributes for tag ``.
302 | *
303 | * To insert dropdown divider use `-`.
304 | */
305 | public function items(array $value): self
306 | {
307 | $new = clone $this;
308 | $new->items = $value;
309 |
310 | return $new;
311 | }
312 |
313 | /**
314 | * Returns a new instance with the specified items' container HTML attributes.
315 | *
316 | * @param array $valuesMap Attribute values indexed by attribute names.
317 | */
318 | public function itemsContainerAttributes(array $valuesMap): self
319 | {
320 | $new = clone $this;
321 | $new->itemsContainerAttributes = $valuesMap;
322 |
323 | return $new;
324 | }
325 |
326 | /**
327 | * Returns a new instance with the specified item container class.
328 | *
329 | * @param string $value The item container class.
330 | */
331 | public function itemsContainerClass(string $value): self
332 | {
333 | $new = clone $this;
334 | Html::addCssClass($new->itemsContainerAttributes, $value);
335 |
336 | return $new;
337 | }
338 |
339 | /**
340 | * Returns a new instance with the specified items' container tag.
341 | *
342 | * @param string $value The items' container tag.
343 | */
344 | public function itemsContainerTag(string $value): self
345 | {
346 | $new = clone $this;
347 | $new->itemsContainerTag = $value;
348 |
349 | return $new;
350 | }
351 |
352 | /**
353 | * Returns a new instance with the specified split button attributes.
354 | *
355 | * @param array $valuesMap Attribute values indexed by attribute names.
356 | */
357 | public function splitButtonAttributes(array $valuesMap): self
358 | {
359 | $new = clone $this;
360 | $new->splitButtonAttributes = $valuesMap;
361 |
362 | return $new;
363 | }
364 |
365 | /**
366 | * Returns a new instance with the specified split button class.
367 | *
368 | * @param string $value The split button class.
369 | */
370 | public function splitButtonClass(string $value): self
371 | {
372 | $new = clone $this;
373 | Html::addCssClass($new->splitButtonAttributes, $value);
374 |
375 | return $new;
376 | }
377 |
378 | /**
379 | * Returns a new instance with the specified split button span class.
380 | *
381 | * @param string $value The split button span class.
382 | */
383 | public function splitButtonSpanClass(string $value): self
384 | {
385 | $new = clone $this;
386 | Html::addCssClass($new->splitButtonSpanAttributes, $value);
387 |
388 | return $new;
389 | }
390 |
391 | /**
392 | * Returns a new instance with the specified toggle HTML attributes.
393 | *
394 | * @param array $valuesMap Attribute values indexed by attribute names.
395 | */
396 | public function toggleAttributes(array $valuesMap): self
397 | {
398 | $new = clone $this;
399 | $new->toggleAttributes = $valuesMap;
400 |
401 | return $new;
402 | }
403 |
404 | /**
405 | * Returns a new instance with the specified toggle class.
406 | *
407 | * @param string $value The toggle class.
408 | */
409 | public function toggleClass(string $value): self
410 | {
411 | $new = clone $this;
412 | Html::addCssClass($new->toggleAttributes, $value);
413 |
414 | return $new;
415 | }
416 |
417 | /**
418 | * Returns a new instance with the specified toggle type, if `button` the toggle will be a button, otherwise a
419 | * `a` tag will be used.
420 | *
421 | * @param string $value The toggle tag.
422 | */
423 | public function toggleType(string $value): self
424 | {
425 | $new = clone $this;
426 | $new->toggleType = $value;
427 |
428 | return $new;
429 | }
430 |
431 | /**
432 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
433 | */
434 | public function render(): string
435 | {
436 | /**
437 | * @psalm-var array<
438 | * array-key,
439 | * array{
440 | * label: string,
441 | * link: string,
442 | * linkAttributes: array,
443 | * active: bool,
444 | * disabled: bool,
445 | * enclose: bool,
446 | * headerAttributes: array,
447 | * itemContainerAttributes: array,
448 | * toggleAttributes: array,
449 | * visible: bool,
450 | * items: array,
451 | * }|string
452 | * > $normalizedItems
453 | */
454 | $normalizedItems = Helper\Normalizer::dropdown($this->items);
455 |
456 | $containerAttributes = $this->containerAttributes;
457 |
458 | $items = $this->renderItems($normalizedItems) . PHP_EOL;
459 |
460 | if (trim($items) === '') {
461 | return '';
462 | }
463 |
464 | if ($this->containerClass !== '') {
465 | Html::addCssClass($containerAttributes, $this->containerClass);
466 | }
467 |
468 | if ($this->containerTag === '') {
469 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
470 | }
471 |
472 | return match ($this->container) {
473 | true => Html::normalTag($this->containerTag, $items, $containerAttributes)->encode(false)->render(),
474 | false => $items,
475 | };
476 | }
477 |
478 | private function renderDivider(): string
479 | {
480 | $dividerAttributes = $this->dividerAttributes;
481 |
482 | if ($this->dividerClass !== '') {
483 | Html::addCssClass($dividerAttributes, $this->dividerClass);
484 | }
485 |
486 | if ($this->dividerTag === '') {
487 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
488 | }
489 |
490 | return $this->renderItemContainer(
491 | Html::tag($this->dividerTag, '', $dividerAttributes)->encode(false)->render(),
492 | );
493 | }
494 |
495 | /**
496 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
497 | */
498 | private function renderDropdown(array $items): string
499 | {
500 | return self::widget()
501 | ->container(false)
502 | ->dividerAttributes($this->dividerAttributes)
503 | ->headerClass($this->headerClass)
504 | ->headerTag($this->headerTag)
505 | ->itemClass($this->itemClass)
506 | ->itemContainerAttributes($this->itemContainerAttributes)
507 | ->itemContainerTag($this->itemContainerTag)
508 | ->items($items)
509 | ->itemsContainerAttributes(array_merge($this->itemsContainerAttributes))
510 | ->itemTag($this->itemTag)
511 | ->toggleAttributes($this->toggleAttributes)
512 | ->toggleType($this->toggleType)
513 | ->render();
514 | }
515 |
516 | private function renderHeader(string $label, array $headerAttributes = []): string
517 | {
518 | if ($this->headerClass !== '') {
519 | Html::addCssClass($headerAttributes, $this->headerClass);
520 | }
521 |
522 | if ($this->headerTag === '') {
523 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
524 | }
525 |
526 | return $this->renderItemContainer(
527 | Html::normalTag($this->headerTag, $label, $headerAttributes)->encode(false)->render(),
528 | );
529 | }
530 |
531 | /**
532 | * @param array $item The item to be rendered.
533 | *
534 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
535 | *
536 | * @psalm-param array{
537 | * label: string,
538 | * link: string,
539 | * linkAttributes: array,
540 | * active: bool,
541 | * disabled: bool,
542 | * enclose: bool,
543 | * headerAttributes: array,
544 | * itemContainerAttributes: array,
545 | * toggleAttributes: array,
546 | * visible: bool,
547 | * items: array,
548 | * } $item
549 | */
550 | private function renderItem(array $item): string
551 | {
552 | if ($item['visible'] === false) {
553 | return '';
554 | }
555 |
556 | $lines = [];
557 | $linkAttributes = $item['linkAttributes'];
558 |
559 | if ($this->itemClass !== '') {
560 | Html::addCssClass($linkAttributes, $this->itemClass);
561 | }
562 |
563 | if ($item['active']) {
564 | $linkAttributes['aria-current'] = 'true';
565 | Html::addCssClass($linkAttributes, [$this->activeClass]);
566 | }
567 |
568 | if ($item['disabled']) {
569 | Html::addCssClass($linkAttributes, $this->disabledClass);
570 | }
571 |
572 | if ($item['items'] === []) {
573 | $lines[] = $this->renderItemContent(
574 | $item['label'],
575 | $item['link'],
576 | $item['enclose'],
577 | $linkAttributes,
578 | $item['headerAttributes'],
579 | $item['itemContainerAttributes'],
580 | );
581 | } else {
582 | $itemContainer = $this->renderItemsContainer($this->renderDropdown($item['items']));
583 | $toggle = $this->renderToggle($item['label'], $item['link'], $item['toggleAttributes']);
584 | $toggleSplitButton = $this->renderToggleSplitButton($item['label']);
585 |
586 | if ($this->toggleType === 'split' && !str_contains($this->containerClass, 'dropstart')) {
587 | $lines[] = $toggleSplitButton . PHP_EOL . $toggle . PHP_EOL . $itemContainer;
588 | } elseif ($this->toggleType === 'split' && str_contains($this->containerClass, 'dropstart')) {
589 | $lines[] = $toggle . PHP_EOL . $itemContainer . PHP_EOL . $toggleSplitButton;
590 | } else {
591 | $lines[] = $toggle . PHP_EOL . $itemContainer;
592 | }
593 | }
594 |
595 | /** @psalm-var string[] $lines */
596 | return implode(PHP_EOL, $lines);
597 | }
598 |
599 | private function renderItemContainer(string $content, array $itemContainerAttributes = []): string
600 | {
601 | if ($this->itemContainerTag === '') {
602 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
603 | }
604 |
605 | if ($itemContainerAttributes === []) {
606 | $itemContainerAttributes = $this->itemContainerAttributes;
607 | }
608 |
609 | return Html::normalTag($this->itemContainerTag, $content, $itemContainerAttributes)
610 | ->encode(false)
611 | ->render();
612 | }
613 |
614 | private function renderItemsContainer(string $content): string
615 | {
616 | $itemsContainerAttributes = $this->itemsContainerAttributes;
617 |
618 | if ($this->id !== '') {
619 | $itemsContainerAttributes['aria-labelledby'] = $this->id;
620 | }
621 |
622 | if ($this->itemsContainerTag === '') {
623 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
624 | }
625 |
626 | return Html::normalTag($this->itemsContainerTag, $content, $itemsContainerAttributes)
627 | ->encode(false)
628 | ->render();
629 | }
630 |
631 | private function renderItemContent(
632 | string $label,
633 | string $link,
634 | bool $enclose,
635 | array $linkAttributes = [],
636 | array $headerAttributes = [],
637 | array $itemContainerAttributes = [],
638 | ): string {
639 | return match (true) {
640 | $label === '-' => $this->renderDivider(),
641 | $enclose === false => $label,
642 | $link === '' => $this->renderHeader($label, $headerAttributes),
643 | default => $this->renderItemLink($label, $link, $linkAttributes, $itemContainerAttributes),
644 | };
645 | }
646 |
647 | /**
648 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
649 | *
650 | * @psalm-param array<
651 | * array-key,
652 | * array{
653 | * label: string,
654 | * link: string,
655 | * linkAttributes: array,
656 | * active: bool,
657 | * disabled: bool,
658 | * enclose: bool,
659 | * headerAttributes: array,
660 | * itemContainerAttributes: array,
661 | * toggleAttributes: array,
662 | * visible: bool,
663 | * items: array,
664 | * }|string
665 | * > $items
666 | */
667 | private function renderItems(array $items = []): string
668 | {
669 | $lines = [];
670 |
671 | foreach ($items as $item) {
672 | $line = match (gettype($item)) {
673 | 'array' => $this->renderItem($item),
674 | 'string' => $this->renderDivider(),
675 | };
676 |
677 | if ($line !== '') {
678 | $lines[] = $line;
679 | }
680 | }
681 |
682 | return PHP_EOL . implode(PHP_EOL, $lines);
683 | }
684 |
685 | private function renderItemLink(
686 | string $label,
687 | string $link,
688 | array $linkAttributes = [],
689 | array $itemContainerAttributes = []
690 | ): string {
691 | $linkAttributes['href'] = $link;
692 |
693 | if ($this->itemTag === '') {
694 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
695 | }
696 |
697 | $linkTag = Html::normalTag($this->itemTag, $label, $linkAttributes)->encode(false)->render();
698 |
699 | return match ($this->itemContainer) {
700 | true => $this->renderItemContainer($linkTag, $itemContainerAttributes),
701 | default => $linkTag,
702 | };
703 | }
704 |
705 | private function renderToggle(string $label, string $link, array $toggleAttributes = []): string
706 | {
707 | if ($toggleAttributes === []) {
708 | $toggleAttributes = $this->toggleAttributes;
709 | }
710 |
711 | if ($this->id !== '') {
712 | $toggleAttributes['id'] = $this->id;
713 | }
714 |
715 | return match ($this->toggleType) {
716 | 'link' => $this->renderToggleLink($label, $link, $toggleAttributes),
717 | 'split' => $this->renderToggleSplit($label, $toggleAttributes),
718 | default => $this->renderToggleButton($label, $toggleAttributes),
719 | };
720 | }
721 |
722 | private function renderToggleButton(string $label, array $toggleAttributes = []): string
723 | {
724 | return Button::tag()->attributes($toggleAttributes)->content($label)->type('button')->render();
725 | }
726 |
727 | private function renderToggleLink(string $label, string $link, array $toggleAttributes = []): string
728 | {
729 | return A::tag()->attributes($toggleAttributes)->content($label)->href($link)->render();
730 | }
731 |
732 | private function renderToggleSplit(string $label, array $toggleAttributes = []): string
733 | {
734 | return Button::tag()
735 | ->attributes($toggleAttributes)
736 | ->content(Span::tag()->attributes($this->splitButtonSpanAttributes)->content($label))
737 | ->type('button')
738 | ->render();
739 | }
740 |
741 | private function renderToggleSplitButton(string $label): string
742 | {
743 | return Button::tag()->attributes($this->splitButtonAttributes)->content($label)->type('button')->render();
744 | }
745 | }
746 |
--------------------------------------------------------------------------------
/src/FragmentCache.php:
--------------------------------------------------------------------------------
1 | 'string-a', 'b' => 'string-b']);
30 | *
31 | * FragmentCache::widget()
32 | * ->id('cache-id')
33 | * ->ttl(30)
34 | * ->dynamicContents($dynamicContent)
35 | * ->begin();
36 | * echo 'Content to be cached ...';
37 | * echo $dynamicContent->placeholder();
38 | * echo 'Content to be cached ...';
39 | * FragmentCache::end();
40 | * ```
41 | */
42 | final class FragmentCache extends Widget
43 | {
44 | private Dependency|null $dependency = null;
45 | private string $id = '';
46 | private int $ttl = 60;
47 | /** @psalm-var string[] */
48 | private array $variations = [];
49 |
50 | /**
51 | * @var array
52 | */
53 | private array $dynamicContents = [];
54 |
55 | public function __construct(private CacheInterface $cache)
56 | {
57 | }
58 |
59 | /**
60 | * Returns a new instance with the specified dynamic contents.
61 | *
62 | * @param DynamicContent ...$value The dynamic content instances.
63 | */
64 | public function dynamicContents(DynamicContent ...$value): self
65 | {
66 | $new = clone $this;
67 |
68 | foreach ($value as $dynamicContent) {
69 | $new->dynamicContents[$dynamicContent->id()] = $dynamicContent;
70 | }
71 |
72 | return $new;
73 | }
74 |
75 | /**
76 | * Returns a new instance with the specified Widget ID.
77 | *
78 | * @param string $value The unique identifier of the cache fragment.
79 | */
80 | public function id(string $value): self
81 | {
82 | $new = clone $this;
83 | $new->id = $value;
84 |
85 | return $new;
86 | }
87 |
88 | /**
89 | * Returns a new instance with the specified dependency.
90 | *
91 | * @param Dependency $value The dependency that the cached content depends on.
92 | *
93 | * This can be either a {@see Dependency} object or a configuration array for creating the dependency object.
94 | *
95 | * Would make the output cache depends on the last modified time of all posts. If any post has its modification time
96 | * changed, the cached content would be invalidated.
97 | */
98 | public function dependency(Dependency $value): self
99 | {
100 | $new = clone $this;
101 | $new->dependency = $value;
102 |
103 | return $new;
104 | }
105 |
106 | /**
107 | * Returns a new instance with the specified TTL.
108 | *
109 | * @param int $value The number of seconds that the data can remain valid in cache.
110 | */
111 | public function ttl(int $value): self
112 | {
113 | $new = clone $this;
114 | $new->ttl = $value;
115 |
116 | return $new;
117 | }
118 |
119 | /**
120 | * Returns a new instance with the specified variations.
121 | *
122 | * @param string ...$value The factors that would cause the variation of the content being cached.
123 | *
124 | * Each factor is a string representing a variation (e.g. the language, a GET parameter). The following variation
125 | * setting will cause the content to be cached in different versions according to the current application language:
126 | *
127 | * ```php
128 | * $fragmentCache->variations('en');
129 | * ```
130 | */
131 | public function variations(string ...$value): self
132 | {
133 | $new = clone $this;
134 | $new->variations = $value;
135 |
136 | return $new;
137 | }
138 |
139 | /**
140 | * Starts recording a fragment cache.
141 | */
142 | public function begin(): ?string
143 | {
144 | parent::begin();
145 |
146 | ob_start();
147 |
148 | return null;
149 | }
150 |
151 | /**
152 | * Marks the end of content to be cached.
153 | *
154 | * Content displayed before this method call and after {@see begin()} will be captured and saved in cache.
155 | *
156 | * This method does nothing if valid content is already found in cache.
157 | *
158 | * @return string The result of widget execution to be outputted.
159 | */
160 | public function render(): string
161 | {
162 | if ($this->id === '') {
163 | ob_end_clean();
164 | throw new RuntimeException('You must assign the "id" using the "id()" setter.');
165 | }
166 |
167 | $cachedContent = new CachedContent($this->id, $this->cache, $this->dynamicContents, $this->variations);
168 | $content = $cachedContent->get();
169 |
170 | if ($content !== null) {
171 | ob_end_clean();
172 | return $content;
173 | }
174 |
175 | $content = ob_get_clean();
176 |
177 | if ($content === false || $content === '') {
178 | return '';
179 | }
180 |
181 | return $cachedContent->cache($content, $this->ttl, $this->dependency);
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/src/Helper/Normalizer.php:
--------------------------------------------------------------------------------
1 | $child) {
24 | if (is_array($child)) {
25 | $items[$i]['label'] = self::renderLabel(
26 | self::label($child),
27 | self::icon($child),
28 | self::iconAttributes($child),
29 | self::iconClass($child),
30 | self::iconContainerAttributes($child),
31 | );
32 | $items[$i]['active'] = self::active($child, '', '', false);
33 | $items[$i]['disabled'] = self::disabled($child);
34 | $items[$i]['enclose'] = self::enclose($child);
35 | $items[$i]['headerAttributes'] = self::headerAttributes($child);
36 | $items[$i]['itemContainerAttributes'] = self::itemContainerAttributes($child);
37 | $items[$i]['link'] = self::link($child, '/');
38 | $items[$i]['linkAttributes'] = self::linkAttributes($child);
39 | $items[$i]['toggleAttributes'] = self::toggleAttributes($child);
40 | $items[$i]['visible'] = self::visible($child);
41 |
42 | if (isset($child['items']) && is_array($child['items'])) {
43 | $items[$i]['items'] = self::dropdown($child['items']);
44 | } else {
45 | $items[$i]['items'] = [];
46 | }
47 | }
48 | }
49 |
50 | return $items;
51 | }
52 |
53 | /**
54 | * Normalize the given array of items for the menu.
55 | */
56 | public static function menu(
57 | array $items,
58 | string $currentPath,
59 | bool $activateItems,
60 | array $iconContainerAttributes = []
61 | ): array {
62 | /**
63 | * @psalm-var array[] $items
64 | * @psalm-suppress RedundantConditionGivenDocblockType
65 | */
66 | foreach ($items as $i => $child) {
67 | if (is_array($child)) {
68 | if (isset($child['items']) && is_array($child['items'])) {
69 | $items[$i]['items'] = self::menu(
70 | $child['items'],
71 | $currentPath,
72 | $activateItems,
73 | $iconContainerAttributes,
74 | );
75 | } else {
76 | $items[$i]['link'] = self::link($child);
77 | $items[$i]['linkAttributes'] = self::linkAttributes($child);
78 | $items[$i]['active'] = self::active(
79 | $child,
80 | $items[$i]['link'],
81 | $currentPath,
82 | $activateItems
83 | );
84 | $items[$i]['disabled'] = self::disabled($child);
85 | $items[$i]['visible'] = self::visible($child);
86 | $items[$i]['label'] = self::renderLabel(
87 | self::label($child),
88 | self::icon($child),
89 | self::iconAttributes($child),
90 | self::iconClass($child),
91 | self::iconContainerAttributes($child, $iconContainerAttributes),
92 | );
93 | }
94 | }
95 | }
96 |
97 | return $items;
98 | }
99 |
100 | public static function renderLabel(
101 | string $label,
102 | string $icon,
103 | array $iconAttributes,
104 | string $iconClass,
105 | array $iconContainerAttributes
106 | ): string {
107 | $html = '';
108 |
109 | if ($iconClass !== '') {
110 | Html::addCssClass($iconAttributes, $iconClass);
111 | }
112 |
113 | if ($icon !== '' || $iconAttributes !== [] || $iconClass !== '') {
114 | $i = I::tag()->attributes($iconAttributes)->content($icon);
115 | $html = Span::tag()->attributes($iconContainerAttributes)->content($i)->encode(false)->render();
116 | }
117 |
118 | if ($label !== '') {
119 | $html .= $label;
120 | }
121 |
122 | return $html;
123 | }
124 |
125 | private static function active(array $item, string $link, string $currentPath, bool $activateItems): bool
126 | {
127 | if (!array_key_exists('active', $item)) {
128 | return self::isItemActive($link, $currentPath, $activateItems);
129 | }
130 |
131 | return is_bool($item['active']) ? $item['active'] : false;
132 | }
133 |
134 | private static function disabled(array $item): bool
135 | {
136 | return array_key_exists('disabled', $item) && is_bool($item['disabled']) ? $item['disabled'] : false;
137 | }
138 |
139 | private static function enclose(array $item): bool
140 | {
141 | return array_key_exists('enclose', $item) && is_bool($item['enclose']) ? $item['enclose'] : true;
142 | }
143 |
144 | private static function headerAttributes(array $item): array
145 | {
146 | return array_key_exists('headerAttributes', $item) && is_array($item['headerAttributes'])
147 | ? $item['headerAttributes']
148 | : [];
149 | }
150 |
151 | private static function icon(array $item): string
152 | {
153 | return array_key_exists('icon', $item) && is_string($item['icon']) ? $item['icon'] : '';
154 | }
155 |
156 | private static function iconAttributes(array $item): array
157 | {
158 | return array_key_exists('iconAttributes', $item) && is_array($item['iconAttributes'])
159 | ? $item['iconAttributes'] : [];
160 | }
161 |
162 | private static function iconClass(array $item): string
163 | {
164 | return array_key_exists('iconClass', $item) && is_string($item['iconClass']) ? $item['iconClass'] : '';
165 | }
166 |
167 | private static function iconContainerAttributes(array $item, array $iconContainerAttributes = []): array
168 | {
169 | return array_key_exists('iconContainerAttributes', $item) && is_array($item['iconContainerAttributes'])
170 | ? $item['iconContainerAttributes'] : $iconContainerAttributes;
171 | }
172 |
173 | /**
174 | * Checks whether a menu item is active.
175 | *
176 | * This is done by checking match that specified in the `url` option of the menu item.
177 | *
178 | * @param string $link The link of the menu item.
179 | * @param string $currentPath The current path.
180 | * @param bool $activateItems Whether to activate items having no link.
181 | *
182 | * @return bool Whether the menu item is active.
183 | */
184 | private static function isItemActive(string $link, string $currentPath, bool $activateItems): bool
185 | {
186 | return $link === $currentPath && $activateItems;
187 | }
188 |
189 | private static function itemContainerAttributes(array $item): array
190 | {
191 | return array_key_exists('itemContainerAttributes', $item) && is_array($item['itemContainerAttributes'])
192 | ? $item['itemContainerAttributes'] : [];
193 | }
194 |
195 | private static function label(array $item): string
196 | {
197 | if (!isset($item['label'])) {
198 | throw new InvalidArgumentException('The "label" option is required.');
199 | }
200 |
201 | if (!is_string($item['label'])) {
202 | throw new InvalidArgumentException('The "label" option must be a string.');
203 | }
204 |
205 | if ($item['label'] === '' && !isset($item['icon'])) {
206 | throw new InvalidArgumentException('The "label" cannot be an empty string.');
207 | }
208 |
209 | /** @var bool */
210 | $encode = $item['encode'] ?? true;
211 |
212 | return $encode ? Html::encode($item['label']) : $item['label'];
213 | }
214 |
215 | private static function link(array $item, string $defaultValue = ''): string
216 | {
217 | return array_key_exists('link', $item) && is_string($item['link']) ? $item['link'] : $defaultValue;
218 | }
219 |
220 | private static function linkAttributes(array $item): array
221 | {
222 | return array_key_exists('linkAttributes', $item) && is_array($item['linkAttributes'])
223 | ? $item['linkAttributes'] : [];
224 | }
225 |
226 | private static function toggleAttributes(array $item): array
227 | {
228 | return array_key_exists('toggleAttributes', $item) && is_array($item['toggleAttributes'])
229 | ? $item['toggleAttributes'] : [];
230 | }
231 |
232 | private static function visible(array $item): bool
233 | {
234 | return array_key_exists('visible', $item) && is_bool($item['visible']) ? $item['visible'] : true;
235 | }
236 | }
237 |
--------------------------------------------------------------------------------
/src/Menu.php:
--------------------------------------------------------------------------------
1 | items([
38 | * ['label' => 'Login', 'link' => 'site/login', 'visible' => true],
39 | * ]);
40 | * ?>
41 | * ```
42 | */
43 | final class Menu extends Widget
44 | {
45 | private array $afterAttributes = [];
46 | private string $afterContent = '';
47 | private string $afterTag = 'span';
48 | private string $activeClass = 'active';
49 | private bool $activateItems = true;
50 | private array $attributes = [];
51 | private array $beforeAttributes = [];
52 | private string $beforeContent = '';
53 | private string $beforeTag = 'span';
54 | private bool $container = true;
55 | private string $currentPath = '';
56 | private string $disabledClass = 'disabled';
57 | private bool $dropdownContainer = true;
58 | private array $dropdownContainerAttributes = [];
59 | private string $dropdownContainerTag = 'li';
60 | private array $dropdownDefinitions = [];
61 | private string $firstItemClass = '';
62 | private array $iconContainerAttributes = [];
63 | private array $items = [];
64 | private bool $itemsContainer = true;
65 | private array $itemsContainerAttributes = [];
66 | private string $itemsTag = 'li';
67 | private string $lastItemClass = '';
68 | private array $linkAttributes = [];
69 | private string $linkClass = '';
70 | private string $linkTag = 'a';
71 | private string $tagName = 'ul';
72 | private string $template = '{items}';
73 |
74 | /**
75 | * Return new instance with specified whether to activate parent menu items when one of the corresponding child menu
76 | * items is active.
77 | *
78 | * @param bool $value The value to be assigned to the activateItems property.
79 | */
80 | public function activateItems(bool $value): self
81 | {
82 | $new = clone $this;
83 | $new->activateItems = $value;
84 |
85 | return $new;
86 | }
87 |
88 | /**
89 | * Returns a new instance with the specified active CSS class.
90 | *
91 | * @param string $value The CSS class to be appended to the active menu item.
92 | */
93 | public function activeClass(string $value): self
94 | {
95 | $new = clone $this;
96 | $new->activeClass = $value;
97 |
98 | return $new;
99 | }
100 |
101 | /**
102 | * Returns a new instance with the specified after container attributes.
103 | *
104 | * @param array $valuesMap Attribute values indexed by attribute names.
105 | */
106 | public function afterAttributes(array $valuesMap): self
107 | {
108 | $new = clone $this;
109 | $new->afterAttributes = $valuesMap;
110 |
111 | return $new;
112 | }
113 |
114 | /**
115 | * Returns a new instance with the specified after container class.
116 | *
117 | * @param string $value The class name.
118 | */
119 | public function afterClass(string $value): self
120 | {
121 | $new = clone $this;
122 | Html::addCssClass($new->afterAttributes, $value);
123 |
124 | return $new;
125 | }
126 |
127 | /**
128 | * Returns a new instance with the specified after content.
129 | *
130 | * @param string|Stringable $content The content.
131 | */
132 | public function afterContent(string|Stringable $content): self
133 | {
134 | $new = clone $this;
135 | $new->afterContent = (string) $content;
136 |
137 | return $new;
138 | }
139 |
140 | /**
141 | * Returns a new instance with the specified after container tag.
142 | *
143 | * @param string $value The after container tag.
144 | */
145 | public function afterTag(string $value): self
146 | {
147 | $new = clone $this;
148 | $new->afterTag = $value;
149 |
150 | return $new;
151 | }
152 |
153 | /**
154 | * Returns a new instance with the HTML attributes. The following special options are recognized.
155 | *
156 | * @param array $valuesMap Attribute values indexed by attribute names.
157 | */
158 | public function attributes(array $valuesMap): self
159 | {
160 | $new = clone $this;
161 | $new->attributes = $valuesMap;
162 |
163 | return $new;
164 | }
165 |
166 | /**
167 | * Returns a new instance with the specified before container attributes.
168 | *
169 | * @param array $valuesMap Attribute values indexed by attribute names.
170 | */
171 | public function beforeAttributes(array $valuesMap): self
172 | {
173 | $new = clone $this;
174 | $new->beforeAttributes = $valuesMap;
175 |
176 | return $new;
177 | }
178 |
179 | /**
180 | * Returns a new instance with the specified before container class.
181 | *
182 | * @param string $value The before container class.
183 | */
184 | public function beforeClass(string $value): self
185 | {
186 | $new = clone $this;
187 | Html::addCssClass($new->beforeAttributes, $value);
188 |
189 | return $new;
190 | }
191 |
192 | /**
193 | * Returns a new instance with the specified before content.
194 | *
195 | * @param string|Stringable $value The content.
196 | */
197 | public function beforeContent(string|Stringable $value): self
198 | {
199 | $new = clone $this;
200 | $new->beforeContent = (string) $value;
201 |
202 | return $new;
203 | }
204 |
205 | /**
206 | * Returns a new instance with the specified before container tag.
207 | *
208 | * @param string $value The before container tag.
209 | */
210 | public function beforeTag(string $value): self
211 | {
212 | $new = clone $this;
213 | $new->beforeTag = $value;
214 |
215 | return $new;
216 | }
217 |
218 | /**
219 | * Returns a new instance with the specified the class `menu` widget.
220 | *
221 | * @param string $value The class `menu` widget.
222 | */
223 | public function class(string $value): self
224 | {
225 | $new = clone $this;
226 | Html::addCssClass($new->attributes, $value);
227 |
228 | return $new;
229 | }
230 |
231 | /**
232 | * Returns a new instance with the specified enable or disable the container widget.
233 | *
234 | * @param bool $value The container widget enable or disable, for default is `true`.
235 | */
236 | public function container(bool $value): self
237 | {
238 | $new = clone $this;
239 | $new->container = $value;
240 |
241 | return $new;
242 | }
243 |
244 | /**
245 | * Returns a new instance with the specified the current path.
246 | *
247 | * @param string $value The current path.
248 | */
249 | public function currentPath(string $value): self
250 | {
251 | $new = clone $this;
252 | $new->currentPath = $value;
253 |
254 | return $new;
255 | }
256 |
257 | /**
258 | * Returns a new instance with the specified disabled CSS class.
259 | *
260 | * @param string $value The CSS class to be appended to the disabled menu item.
261 | */
262 | public function disabledClass(string $value): self
263 | {
264 | $new = clone $this;
265 | $new->disabledClass = $value;
266 |
267 | return $new;
268 | }
269 |
270 | /**
271 | * Returns a new instance with the specified dropdown container class.
272 | *
273 | * @param string $value The dropdown container class.
274 | */
275 | public function dropdownContainerClass(string $value): self
276 | {
277 | $new = clone $this;
278 | Html::addCssClass($new->dropdownContainerAttributes, $value);
279 |
280 | return $new;
281 | }
282 |
283 | /**
284 | * Returns a new instance with the specified dropdown container tag.
285 | *
286 | * @param string $value The dropdown container tag.
287 | */
288 | public function dropdownContainerTag(string $value): self
289 | {
290 | $new = clone $this;
291 | $new->dropdownContainerTag = $value;
292 |
293 | return $new;
294 | }
295 |
296 | /**
297 | * Returns a new instance with the specified dropdown definition widget.
298 | *
299 | * @param array $valuesMap The dropdown definition widget.
300 | */
301 | public function dropdownDefinitions(array $valuesMap): self
302 | {
303 | $new = clone $this;
304 | $new->dropdownDefinitions = $valuesMap;
305 |
306 | return $new;
307 | }
308 |
309 | /**
310 | * Returns a new instance with the specified first item CSS class.
311 | *
312 | * @param string $value The CSS class that will be assigned to the first item in the main menu or each submenu.
313 | */
314 | public function firstItemClass(string $value): self
315 | {
316 | $new = clone $this;
317 | $new->firstItemClass = $value;
318 |
319 | return $new;
320 | }
321 |
322 | /**
323 | * Returns a new instance with the specified icon container attributes.
324 | *
325 | * @param array $valuesMap Attribute values indexed by attribute names.
326 | */
327 | public function iconContainerAttributes(array $valuesMap): self
328 | {
329 | $new = clone $this;
330 | $new->iconContainerAttributes = $valuesMap;
331 |
332 | return $new;
333 | }
334 |
335 | /**
336 | * List of items in the nav widget. Each array element represents a single menu item which can be either a string or
337 | * an array with the following structure:
338 | *
339 | * - label: string, required, the nav item label.
340 | * - active: bool, whether the item should be on active state or not.
341 | * - disabled: bool, whether the item should be on disabled state or not. For default `disabled` is false.
342 | * - encode: bool, whether the label should be HTML encoded or not. For default `encodeLabel` is true.
343 | * - items: array, optional, the item's submenu items. The structure is the same as for `items` option.
344 | * - itemsContainerAttributes: array, optional, the HTML attributes for the item's submenu container.
345 | * - link: string, the item's href. Defaults to "#". For default `link` is "#".
346 | * - linkAttributes: array, the HTML attributes of the item's link. For default `linkAttributes` is `[]`.
347 | * - icon: string, the item's icon. For default is ``.
348 | * - iconAttributes: array, the HTML attributes of the item's icon. For default `iconAttributes` is `[]`.
349 | * - iconClass: string, the item's icon CSS class. For default is ``.
350 | * - visible: bool, optional, whether this menu item is visible. Defaults to true.
351 | *
352 | * If a menu item is a string, it will be rendered directly without HTML encoding.
353 | *
354 | * @param array $valuesMap the list of items to be rendered.
355 | */
356 | public function items(array $valuesMap): self
357 | {
358 | $new = clone $this;
359 | $new->items = $valuesMap;
360 |
361 | return $new;
362 | }
363 |
364 | /**
365 | * Returns a new instance with the specified if enabled or disabled the items' container.
366 | *
367 | * @param bool $value The items container enable or disable, for default is `true`.
368 | */
369 | public function itemsContainer(bool $value): self
370 | {
371 | $new = clone $this;
372 | $new->itemsContainer = $value;
373 |
374 | return $new;
375 | }
376 |
377 | /**
378 | * Returns a new instance with the specified items' container attributes.
379 | *
380 | * @param array $valuesMap Attribute values indexed by attribute names.
381 | */
382 | public function itemsContainerAttributes(array $valuesMap): self
383 | {
384 | $new = clone $this;
385 | $new-> itemsContainerAttributes = $valuesMap;
386 |
387 | return $new;
388 | }
389 |
390 | /**
391 | * Returns a new instance with the specified items' container class.
392 | *
393 | * @param string $value The CSS class that will be assigned to the items' container.
394 | */
395 | public function itemsContainerClass(string $value): self
396 | {
397 | $new = clone $this;
398 | Html::addCssClass($new->itemsContainerAttributes, $value);
399 |
400 | return $new;
401 | }
402 |
403 | /**
404 | * Returns a new instance with the specified items tag.
405 | *
406 | * @param string $value The tag that will be used to wrap the items.
407 | */
408 | public function itemsTag(string $value): self
409 | {
410 | $new = clone $this;
411 | $new->itemsTag = $value;
412 |
413 | return $new;
414 | }
415 |
416 | /**
417 | * Returns a new instance with the specified last item CSS class.
418 | *
419 | * @param string $value The CSS class that will be assigned to the last item in the main menu or each submenu.
420 | */
421 | public function lastItemClass(string $value): self
422 | {
423 | $new = clone $this;
424 | $new->lastItemClass = $value;
425 |
426 | return $new;
427 | }
428 |
429 | /**
430 | * Returns a new instance with the specified link attributes.
431 | *
432 | * @param array $valuesMap Attribute values indexed by attribute names.
433 | */
434 | public function linkAttributes(array $valuesMap): self
435 | {
436 | $new = clone $this;
437 | $new->linkAttributes = $valuesMap;
438 |
439 | return $new;
440 | }
441 |
442 | /**
443 | * Returns a new instance with the specified link css class.
444 | *
445 | * @param string $value The CSS class that will be assigned to the link.
446 | */
447 | public function linkClass(string $value): self
448 | {
449 | $new = clone $this;
450 | $new->linkClass = $value;
451 |
452 | return $new;
453 | }
454 |
455 | /**
456 | * Returns a new instance with the specified link tag.
457 | *
458 | * @param string $value The tag that will be used to wrap the link.
459 | */
460 | public function linkTag(string $value): self
461 | {
462 | $new = clone $this;
463 | $new->linkTag = $value;
464 |
465 | return $new;
466 | }
467 |
468 | /**
469 | * Returns a new instance with the specified tag for rendering the menu.
470 | *
471 | * @param string $value The tag for rendering the menu.
472 | */
473 | public function tagName(string $value): self
474 | {
475 | $new = clone $this;
476 | $new->tagName = $value;
477 |
478 | return $new;
479 | }
480 |
481 | /**
482 | * Returns a new instance with the specified the template used to render the main menu.
483 | *
484 | * @param string $value The template used to render the main menu. In this template, the token `{items}` will be
485 | * replaced.
486 | */
487 | public function template(string $value): self
488 | {
489 | $new = clone $this;
490 | $new->template = $value;
491 |
492 | return $new;
493 | }
494 |
495 | /**
496 | * Renders the menu.
497 | *
498 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
499 | *
500 | * @return string The result of Widget execution to be outputted.
501 | */
502 | public function render(): string
503 | {
504 | if ($this->items === []) {
505 | return '';
506 | }
507 |
508 | /**
509 | * @psalm-var array<
510 | * array-key,
511 | * array{
512 | * label: string,
513 | * link: string,
514 | * linkAttributes: array,
515 | * active: bool,
516 | * disabled: bool,
517 | * visible: bool,
518 | * items?: array
519 | * }
520 | * > $items
521 | */
522 | $items = Helper\Normalizer::menu(
523 | $this->items,
524 | $this->currentPath,
525 | $this->activateItems,
526 | $this->iconContainerAttributes,
527 | );
528 |
529 | return $this->renderMenu($items);
530 | }
531 |
532 | private function renderAfterContent(): string
533 | {
534 | if ($this->afterTag === '') {
535 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
536 | }
537 |
538 | return PHP_EOL .
539 | Html::normalTag($this->afterTag, $this->afterContent, $this->afterAttributes)
540 | ->encode(false)
541 | ->render();
542 | }
543 |
544 | private function renderBeforeContent(): string
545 | {
546 | if ($this->beforeTag === '') {
547 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
548 | }
549 |
550 | return Html::normalTag($this->beforeTag, $this->beforeContent, $this->beforeAttributes)
551 | ->encode(false)
552 | ->render();
553 | }
554 |
555 | /**
556 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
557 | */
558 | private function renderDropdown(array $items): string
559 | {
560 | $dropdownDefinitions = $this->dropdownDefinitions;
561 |
562 | if ($dropdownDefinitions === []) {
563 | $dropdownDefinitions = [
564 | 'container()' => [false],
565 | 'dividerClass()' => ['dropdown-divider'],
566 | 'toggleAttributes()' => [
567 | ['aria-expanded' => 'false', 'data-bs-toggle' => 'dropdown', 'role' => 'button'],
568 | ],
569 | 'toggleType()' => ['link'],
570 | ];
571 | }
572 |
573 | $dropdown = Dropdown::widget([], $dropdownDefinitions)->items($items)->render();
574 |
575 | if ($this->dropdownContainerTag === '') {
576 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
577 | }
578 |
579 | return match ($this->dropdownContainer) {
580 | true => Html::normalTag($this->dropdownContainerTag, $dropdown, $this->dropdownContainerAttributes)
581 | ->encode(false)
582 | ->render(),
583 | false => $dropdown,
584 | };
585 | }
586 |
587 | /**
588 | * Renders the content of a menu item.
589 | *
590 | * Note that the container and the sub-menus are not rendered here.
591 | *
592 | * @param array $item The menu item to be rendered. Please refer to {@see items} to see what data might be in the
593 | * item.
594 | *
595 | * @return string The rendering result.
596 | *
597 | * @psalm-param array{
598 | * label: string,
599 | * link: string,
600 | * linkAttributes: array,
601 | * active: bool,
602 | * disabled: bool,
603 | * visible: bool,
604 | * items?: array,
605 | * itemsContainerAttributes?: array
606 | * } $item
607 | */
608 | private function renderItem(array $item): string
609 | {
610 | $linkAttributes = array_merge($item['linkAttributes'], $this->linkAttributes);
611 |
612 | if ($this->linkClass !== '') {
613 | Html::addCssClass($linkAttributes, $this->linkClass);
614 | }
615 |
616 | if ($item['active']) {
617 | $linkAttributes['aria-current'] = 'page';
618 | Html::addCssClass($linkAttributes, $this->activeClass);
619 | }
620 |
621 | if ($item['disabled']) {
622 | Html::addCssClass($linkAttributes, $this->disabledClass);
623 | }
624 |
625 | if ($item['link'] !== '') {
626 | $linkAttributes['href'] = $item['link'];
627 | }
628 |
629 | if ($this->linkTag === '') {
630 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
631 | }
632 |
633 | return match (isset($linkAttributes['href'])) {
634 | true => Html::normalTag($this->linkTag, $item['label'], $linkAttributes)->encode(false)->render(),
635 | false => $item['label'],
636 | };
637 | }
638 |
639 | /**
640 | * Recursively renders the menu items (without the container tag).
641 | *
642 | * @param array $items The menu items to be rendered recursively.
643 | *
644 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
645 | *
646 | * @psalm-param array<
647 | * array-key,
648 | * array{
649 | * label: string,
650 | * link: string,
651 | * linkAttributes: array,
652 | * active: bool,
653 | * disabled: bool,
654 | * visible: bool,
655 | * items?: array,
656 | * itemsContainerAttributes?: array
657 | * }
658 | * > $items
659 | */
660 | private function renderItems(array $items): string
661 | {
662 | $lines = [];
663 | $n = count($items);
664 |
665 | foreach ($items as $i => $item) {
666 | if (isset($item['items'])) {
667 | $lines[] = strtr($this->template, ['{items}' => $this->renderDropdown([$item])]);
668 | } elseif ($item['visible']) {
669 | $itemsContainerAttributes = array_merge(
670 | $this->itemsContainerAttributes,
671 | $item['itemsContainerAttributes'] ?? [],
672 | );
673 |
674 | if ($i === 0 && $this->firstItemClass !== '') {
675 | Html::addCssClass($itemsContainerAttributes, $this->firstItemClass);
676 | }
677 |
678 | if ($i === $n - 1 && $this->lastItemClass !== '') {
679 | Html::addCssClass($itemsContainerAttributes, $this->lastItemClass);
680 | }
681 |
682 | $menu = $this->renderItem($item);
683 |
684 | if ($this->itemsTag === '') {
685 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
686 | }
687 |
688 | $lines[] = match ($this->itemsContainer) {
689 | false => $menu,
690 | default => strtr(
691 | $this->template,
692 | [
693 | '{items}' => Html::normalTag($this->itemsTag, $menu, $itemsContainerAttributes)
694 | ->encode(false)
695 | ->render(),
696 | ],
697 | ),
698 | };
699 | }
700 | }
701 |
702 | return PHP_EOL . implode(PHP_EOL, $lines);
703 | }
704 |
705 | /**
706 | * @throws CircularReferenceException|InvalidConfigException|NotFoundException|NotInstantiableException
707 | *
708 | * @psalm-param array<
709 | * array-key,
710 | * array{
711 | * label: string,
712 | * link: string,
713 | * linkAttributes: array,
714 | * active: bool,
715 | * disabled: bool,
716 | * visible: bool,
717 | * items?: array
718 | * }
719 | * > $items
720 | */
721 | private function renderMenu(array $items): string
722 | {
723 | $afterContent = '';
724 | $attributes = $this->attributes;
725 | $beforeContent = '';
726 |
727 | $content = $this->renderItems($items) . PHP_EOL;
728 |
729 | if ($this->beforeContent !== '') {
730 | $beforeContent = $this->renderBeforeContent() . PHP_EOL;
731 | }
732 |
733 | if ($this->afterContent !== '') {
734 | $afterContent = $this->renderAfterContent();
735 | }
736 |
737 | if ($this->tagName === '') {
738 | throw new InvalidArgumentException('Tag name must be a string and cannot be empty.');
739 | }
740 |
741 | return match ($this->container) {
742 | false => $beforeContent . trim($content) . $afterContent,
743 | default => $beforeContent .
744 | Html::normalTag($this->tagName, $content, $attributes)->encode(false) .
745 | $afterContent,
746 | };
747 | }
748 | }
749 |
--------------------------------------------------------------------------------