├── 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 | Yii 4 | 5 |

Yii Widgets

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii-widgets/v/stable.png)](https://packagist.org/packages/yiisoft/yii-widgets) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii-widgets/downloads.png)](https://packagist.org/packages/yiisoft/yii-widgets) 11 | [![Build status](https://github.com/yiisoft/yii-widgets/workflows/build/badge.svg)](https://github.com/yiisoft/yii-widgets/actions?query=workflow%3Abuild) 12 | [![Code Coverage](https://codecov.io/gh/yiisoft/yii-widgets/branch/master/graph/badge.svg?token=eHIV156bku)](https://codecov.io/gh/yiisoft/yii-widgets) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fyii-widgets%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-widgets/master) 14 | [![static analysis](https://github.com/yiisoft/yii-widgets/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/yii-widgets/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/yii-widgets/coverage.svg)](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 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 54 | 55 | ## Follow updates 56 | 57 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 58 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 59 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 60 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 61 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](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 | * 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 | * 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 | --------------------------------------------------------------------------------