├── LICENSE.txt ├── README.md ├── composer.json ├── contrib └── pre-commit ├── package-lock.json ├── package.json ├── src ├── BootstrapUIPlugin.php ├── Command │ ├── BootstrapCommand.php │ ├── CopyLayoutsCommand.php │ ├── InstallCommand.php │ └── ModifyViewCommand.php └── View │ ├── Helper │ ├── BreadcrumbsHelper.php │ ├── FlashHelper.php │ ├── FormHelper.php │ ├── HtmlHelper.php │ ├── OptionsAwareTrait.php │ ├── PaginatorHelper.php │ └── Types │ │ ├── Classes.php │ │ ├── Element.php │ │ ├── Type.php │ │ └── TypeInterface.php │ ├── UIView.php │ ├── UIViewTrait.php │ └── Widget │ ├── BasicWidget.php │ ├── ButtonWidget.php │ ├── DateTimeWidget.php │ ├── FileWidget.php │ ├── InputGroupTrait.php │ ├── SelectBoxWidget.php │ └── TextareaWidget.php ├── templates ├── bake │ ├── Template │ │ ├── add.twig │ │ ├── edit.twig │ │ ├── index.twig │ │ ├── login.twig │ │ └── view.twig │ └── element │ │ ├── form.twig │ │ └── tb_actions.twig ├── element │ └── flash │ │ └── default.php └── layout │ ├── default.php │ └── examples │ ├── cover.php │ ├── dashboard.php │ └── signin.php └── webroot ├── css ├── cover.css ├── dashboard.css └── signin.css ├── font └── bootstrap-icon-sizes.css └── img └── baked-with-cakephp.svg /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Jad Bitar 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bootstrap UI 2 | 3 | [![Build Status][ico-ga]][ga] 4 | [![Coverage Status][ico-coverage]][coverage] 5 | [![Total Downloads][ico-downloads]][package] 6 | [![License][ico-license]][license] 7 | 8 | [ico-ga]: https://img.shields.io/github/actions/workflow/status/FriendsOfCake/bootstrap-ui/ci.yml?branch=master&style=flat-square 9 | [ico-coverage]: https://img.shields.io/codecov/c/github/FriendsOfCake/bootstrap-ui.svg?style=flat-square 10 | [ico-downloads]: https://img.shields.io/packagist/dt/friendsofcake/bootstrap-ui.svg?style=flat-square 11 | [ico-license]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 12 | 13 | [ga]: https://github.com/FriendsOfCake/bootstrap-ui/actions?query=workflow%3ACI+branch%3Amaster 14 | [coverage]: https://codecov.io/github/FriendsOfCake/bootstrap-ui 15 | [package]: https://packagist.org/packages/friendsofcake/bootstrap-ui 16 | [license]: LICENSE.txt 17 | 18 | Transparently use [Bootstrap 5][bs] with [CakePHP 5][cakephp]. 19 | 20 | For version info see [version map](https://github.com/FriendsOfCake/bootstrap-ui/wiki#version-map). 21 | 22 | ## Requirements 23 | 24 | * CakePHP 5.x 25 | * Bootstrap 5.3.x 26 | * npm 6.x 27 | * Bootstrap Icons 1.11.x 28 | 29 | ## What's included? 30 | 31 | - FlashHelper (element types: `error`, `info`, `success`, `warning`) 32 | - FormHelper (align: `default`, `inline`, `horizontal`) 33 | - BreadcrumbsHelper 34 | - HtmlHelper (components: `badge`, `icon`) 35 | - PaginatorHelper 36 | - Widgets (`basic`, `button`, `datetime`, `file`, `select`, `textarea`) 37 | - Example layouts (`cover`, `signin`, `dashboard`) 38 | - Bake templates 39 | 40 | ## Table of contents 41 | 42 | - [Installation](#installation) 43 | - [Setup](#setup) 44 | - [Using the Bootstrap commands](#using-the-bootstrap-commands) 45 | - [Manual setup](#manual-setup) 46 | - [BootstrapUI layouts](#bootstrapui-layouts) 47 | - [Including the Bootstrap framework](#including-the-bootstrap-framework) 48 | - [Bake templates](#bake-templates) 49 | - [Usage](#usage) 50 | - [Contributing](#contributing) 51 | - [License](#license) 52 | 53 | ## Installation 54 | 55 | `cd` to the root of your app folder (where the `composer.json` file is) and run the following [Composer][composer] 56 | command: 57 | 58 | ``` 59 | composer require friendsofcake/bootstrap-ui 60 | ``` 61 | 62 | Then load the plugin using CakePHP's console: 63 | 64 | ``` 65 | bin/cake plugin load BootstrapUI 66 | ``` 67 | 68 | ## Setup 69 | 70 | You can either use the Bootstrap commands to make the necessary changes, or do them manually. 71 | 72 | ### Using the Bootstrap commands 73 | 74 | 1. To install the Bootstrap assets (Bootstrap's CSS/JS files) via npm you can use the `install` 75 | command, or [install them manually](#installing-bootstrap-assets-via-npm): 76 | 77 | ``` 78 | bin/cake bootstrap install 79 | ``` 80 | 81 | This will fetch all assets, copy the distribution assets to the BootstrapUI plugin's webroot directory, and symlink 82 | (or copy) them to your application's `webroot` directory. 83 | 84 | If you want to install the latest minor versions of the assets instead of the exact pinned ones, you can use the 85 | `--latest` option: 86 | 87 | ``` 88 | bin/cake bootstrap install --latest 89 | ``` 90 | 91 | 2. You will need to modify your `src/View/AppView` class to either extend `BootstrapUI\View\UIView` or 92 | use the trait `BootStrapUI\View\UIViewTrait`. For doing this you can either use the `modify_view` command, or 93 | [change your view manually](#appview-setup-using-uiview): 94 | 95 | ``` 96 | bin/cake bootstrap modify_view 97 | ``` 98 | 99 | This will rewrite your `src/View/AppView` like described in [AppView setup using UIView](#appview-setup-using-uiview). 100 | 101 | 3. BootstrapUI ships with some example layouts. You can install them using the `copy_layouts` command, or 102 | [copy them manually](#copying-example-layouts): 103 | 104 | ``` 105 | bin/cake bootstrap copy_layouts 106 | ``` 107 | 108 | This will copy the three example layouts `cover.php`, `dashboard.php` and `signin.php` to your application's 109 | `src/templates/layout/TwitterBootstrap`. 110 | 111 | ### Manual setup 112 | 113 | #### Installing Bootstrap assets via npm 114 | 115 | The [the `install` command](#using-the-bootstrap-commands) installs the Bootstrap assets via [npm], which you can also 116 | do manually if you wish to control which assets are being included, and where they are placed. 117 | 118 | Assuming you are in your application's root: 119 | 120 | ``` 121 | npm install bootstrap@5 bootstrap-icons@1 122 | mkdir -p webroot/css 123 | mkdir -p webroot/font/fonts 124 | mkdir -p webroot/js 125 | cp node_modules/bootstrap/dist/css/bootstrap.css webroot/css/ 126 | cp node_modules/bootstrap/dist/css/bootstrap.min.css webroot/css/ 127 | cp node_modules/bootstrap/dist/js/bootstrap.bundle.js webroot/js/ 128 | cp node_modules/bootstrap/dist/js/bootstrap.bundle.min.js webroot/js/ 129 | cp node_modules/bootstrap-icons/font/bootstrap-icons.css webroot/font/ 130 | cp node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff webroot/font/fonts/ 131 | cp node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 webroot/font/fonts/ 132 | cp vendor/friendsofcake/bootstrap-ui/webroot/font/bootstrap-icon-sizes.css webroot/font/ 133 | ``` 134 | 135 | #### AppView setup using UIView 136 | 137 | For a quick setup, just make your `AppView` class extend `BootstrapUI\View\UIView`. The base class will handle 138 | the initializing and loading of the BootstrapUI `default.php` layout for your app. 139 | 140 | The `src\View\AppView.php` will look something like the following: 141 | 142 | ```php 143 | declare(strict_types=1); 144 | 145 | namespace App\View; 146 | 147 | use BootstrapUI\View\UIView; 148 | 149 | class AppView extends UIView 150 | { 151 | /** 152 | * Initialization hook method. 153 | */ 154 | public function initialize(): void 155 | { 156 | // Don't forget to call parent::initialize() 157 | parent::initialize(); 158 | } 159 | } 160 | ``` 161 | 162 | #### AppView setup using UIViewTrait 163 | 164 | If you're adding BootstrapUI to an existing application, it might be easier to use the trait, as it gives you more 165 | control over the loading of the layout. 166 | 167 | ```php 168 | declare(strict_types=1); 169 | 170 | namespace App\View; 171 | 172 | use BootstrapUI\View\UIViewTrait; 173 | use Cake\View\View; 174 | 175 | class AppView extends View 176 | { 177 | use UIViewTrait; 178 | 179 | /** 180 | * Initialization hook method. 181 | */ 182 | public function initialize(): void 183 | { 184 | parent::initialize(); 185 | 186 | // Call the initializeUI method from UIViewTrait 187 | $this->initializeUI(); 188 | } 189 | } 190 | ``` 191 | 192 | #### Copying example layouts 193 | 194 | In order to be able to use the BootstrapUI example layouts (directly taken from the Bootstrap examples), they need to be 195 | copied to your application's layouts directory, either by using 196 | [the `copy_layouts` command](#using-the-bootstrap-commands), or by copying the files manually: 197 | 198 | ``` 199 | cp -R vendor/friendsofcake/bootstrap-ui/templates/layout/examples templates/layout/TwitterBootstrap 200 | ``` 201 | 202 | ### BootstrapUI layouts 203 | 204 | BootstrapUI comes with its own `default.php` layout file and examples taken from the Bootstrap framework. 205 | 206 | When no layout for the view is defined, the `BootstrapUI\View\UIViewTrait` will load its own `default.php` layout file. 207 | You can override this behavior in two ways. 208 | 209 | - Assign a layout to the template with `$this->setLayout('layout')`. 210 | - Disable auto loading of the layout in `BootstrapUI\View\UIViewTrait` by adding `$this->initializeUI(['layout' => false]);` to your `AppView`'s `initialize()` function. 211 | 212 | #### Using the example layouts 213 | 214 | Once copied into your application's layouts directory (being it via 215 | [the `copy_layouts` command](#using-the-bootstrap-commands) or [manually](#copying-example-layouts)), you can simply 216 | extend the example layouts in your views like so: 217 | 218 | ``` 219 | $this->extend('../layout/TwitterBootstrap/dashboard'); 220 | ``` 221 | 222 | Available types are: 223 | 224 | - `cover` 225 | - `signin` 226 | - `dashboard` 227 | 228 | **NOTE: Remember to set the stylesheets in the layouts you copy.** 229 | 230 | ### Including the Bootstrap framework 231 | 232 | If you are using [the BoostrapUI plugin's default layout](#bootstrapui-layouts), and you have installed the Bootstrap 233 | assets using [the `install` command](#using-the-bootstrap-commands), the required assets should automatically be 234 | included. 235 | 236 | If you wish to use your own layout template, then you need to take care of including the required CSS/JS files yourself. 237 | 238 | If you have installed the assets using [the `install` command](#using-the-bootstrap-commands), you can refer to 239 | them using the standard plugin syntax: 240 | 241 | ```php 242 | // in the 243 | echo $this->Html->css('BootstrapUI.bootstrap.min'); 244 | echo $this->Html->css(['BootstrapUI./font/bootstrap-icons', 'BootstrapUI./font/bootstrap-icon-sizes']); 245 | echo $this->Html->script(['BootstrapUI.bootstrap.bundle.min']); 246 | ``` 247 | 248 | If you have installed the assets manually, you'll need to use paths accordingly. With 249 | [the example copy commands](#installing-bootstrap-assets-via-npm) you could use the standard short path syntax: 250 | 251 | ```php 252 | echo $this->Html->css('bootstrap.min'); 253 | echo $this->Html->css(['/font/bootstrap-icons', '/font/bootstrap-icon-sizes']); 254 | echo $this->Html->script(['bootstrap.bundle.min']); 255 | ``` 256 | 257 | If you're using paths that don't adhere to the CakePHP conventions, you'll have to explicitly specify them: 258 | 259 | ```php 260 | echo $this->Html->css('/path/to/bootstrap.css'); 261 | echo $this->Html->css(['/path/to/bootstrap-icons.css', '/path/to/bootstrap-icon-sizes.css']); 262 | echo $this->Html->script(['/path/to/bootstrap.bundle.js']); 263 | ``` 264 | 265 | ## Bake templates 266 | 267 | For those of you who want even more automation, some bake templates have been included. Use them like so: 268 | 269 | ``` 270 | bin/cake bake [subcommand] -t BootstrapUI 271 | ``` 272 | 273 | Currently, bake templates for the following bake subcommands are included: 274 | 275 | ### `template` 276 | 277 | Additionally to the default `index`, `add`, `edit`, and `view` templates, a `login` template is available too. While 278 | the default CRUD action view templates can be utilized like this: 279 | 280 | ```bash 281 | bin/cake bake template ControllerName -t BootstrapUI 282 | ``` 283 | 284 | the `login` template has to be used explicitly by specifying the action name: 285 | 286 | ```bash 287 | bin/cake bake template ControllerName login -t BootstrapUI 288 | ``` 289 | 290 | ## Usage 291 | 292 | At the core of BootstrapUI is a collection of enhancements for CakePHP core helpers. Among other things, these helpers 293 | replace the HTML templates used to render elements for the views. This allows you to create forms and components that 294 | use the Bootstrap styles. 295 | 296 | The current list of enhanced helpers are: 297 | 298 | - `BootstrapUI\View\Helper\FlashHelper` 299 | - `BootstrapUI\View\Helper\FormHelper` 300 | - `BootstrapUI\View\Helper\HtmlHelper` 301 | - `BootstrapUI\View\Helper\PaginatorHelper` 302 | - `BootstrapUI\View\Helper\BreadcrumbsHelper` 303 | 304 | When the `BootstrapUI\View\UIViewTrait` is initialized it loads the above helpers with the same aliases as the 305 | CakePHP core helpers. That means that when you use `$this->Form->create()` in your views, the helper being used 306 | is from the BootstrapUI plugin. 307 | 308 | ### Basic forms 309 | 310 | ```php 311 | echo $this->Form->create($article); 312 | echo $this->Form->control('title'); 313 | echo $this->Form->control('published', ['type' => 'checkbox']); 314 | echo $this->Form->button('Submit'); 315 | echo $this->Form->end(); 316 | ``` 317 | 318 | will render this HTML: 319 | 320 | ```html 321 |
322 | 323 |
324 | 325 | 326 |
327 |
328 | 329 | 330 | 331 |
332 | 333 | 334 |
335 | ``` 336 | 337 | ### Horizontal forms 338 | 339 | Horizontal forms automatically render labels and controls in separate columns (where applicable), labels in th first 340 | one, and controls in the second one. 341 | 342 | Alignment can be configured via the `align` option, which takes either a list of column sizes for the `md` 343 | [Bootstrap screen-size/breakpoint](https://getbootstrap.com/docs/5.3/layout/breakpoints/), or a matrix of 344 | screen-size/breakpoint names and column sizes. 345 | 346 | The following will use the default `md` screen-size/breakpoint: 347 | 348 | ```php 349 | use BootstrapUI\View\Helper\FormHelper; 350 | 351 | echo $this->Form->create($article, [ 352 | 'align' => [ 353 | FormHelper::GRID_COLUMN_ONE => 4, // first column (span over 4 columns) 354 | FormHelper::GRID_COLUMN_TWO => 8, // second column (span over 8 columns) 355 | ], 356 | ]); 357 | echo $this->Form->control('title'); 358 | echo $this->Form->control('published', ['type' => 'checkbox']); 359 | echo $this->Form->submit(); 360 | echo $this->Form->end(); 361 | ``` 362 | 363 | It will render this HTML: 364 | 365 | ```html 366 |
367 | 368 |
369 | 370 |
371 | 372 |
373 |
374 |
375 |
376 |
377 | 378 | 379 | 380 |
381 |
382 |
383 | 384 | 385 |
386 | ``` 387 | 388 | The following uses a matrix of screen-sizes/breakpoints and column sizes: 389 | 390 | ```php 391 | use BootstrapUI\View\Helper\FormHelper; 392 | 393 | echo $this->Form->create($article, [ 394 | 'align' => [ 395 | // column sizes for the `sm` screen-size/breakpoint 396 | 'sm' => [ 397 | FormHelper::GRID_COLUMN_ONE => 6, 398 | FormHelper::GRID_COLUMN_TWO => 6, 399 | ], 400 | // column sizes for the `md` screen-size/breakpoint 401 | 'md' => [ 402 | FormHelper::GRID_COLUMN_ONE => 4, 403 | FormHelper::GRID_COLUMN_TWO => 8, 404 | ], 405 | ], 406 | ]); 407 | echo $this->Form->control('title'); 408 | echo $this->Form->control('published', ['type' => 'checkbox']); 409 | echo $this->Form->button('Submit'); 410 | echo $this->Form->end(); 411 | ``` 412 | 413 | It will render this HTML: 414 | 415 | ```html 416 |
417 | 418 |
419 | 420 |
421 | 422 |
423 |
424 |
425 |
426 |
427 | 428 | 429 | 430 |
431 |
432 |
433 | 434 | 435 |
436 | ``` 437 | 438 | The default alignment will use the `md` screen-size/breakpoint and the following column sizes: 439 | 440 | ```php 441 | [ 442 | FormHelper::GRID_COLUMN_ONE => 2, 443 | FormHelper::GRID_COLUMN_TWO => 10, 444 | ] 445 | ``` 446 | 447 | ### Inline forms 448 | 449 | Inline forms will render controls on one and the same row, and hide labels for most controls. 450 | 451 | ```php 452 | echo $this->Form->create($article, [ 453 | 'align' => 'inline', 454 | ]); 455 | echo $this->Form->control('title', ['placeholder' => 'Title']); 456 | echo $this->Form->control('published', ['type' => 'checkbox']); 457 | echo $this->Html->div('col-auto', $this->Form->button('Submit')); 458 | echo $this->Form->end(); 459 | ``` 460 | 461 | will render this HTML: 462 | 463 | ```html 464 |
465 | 466 |
467 |
468 | 469 | 470 |
471 |
472 |
473 |
474 | 475 | 476 | 477 |
478 |
479 | 480 |
481 | 482 |
483 |
484 | ``` 485 | 486 | ### Spacing 487 | 488 | Out of the box BootstrapUI applies some default spacing for form controls. For default and horizontal aligned forms, 489 | the `mb-3` [spacing class](https://getbootstrap.com/docs/5.3/utilities/spacing/) is being applied to all controls, 490 | while inline forms are using the `g-3` [gutter class](https://getbootstrap.com/docs/5.3/layout/gutters/). 491 | 492 | This can be changed using the `spacing` option, it applies on a per-helper and per-form basis for all alignments, and 493 | for default/horizontal alignments it also applies on a per-control basis. 494 | 495 | ```php 496 | // for all forms 497 | echo $this->Form->setConfig([ 498 | 'spacing' => 'mb-6', 499 | ]); 500 | ``` 501 | 502 | ```php 503 | // for a specific form 504 | echo $this->Form->create($entity, [ 505 | 'spacing' => 'mb-6', 506 | ]); 507 | ``` 508 | 509 | ```php 510 | // for a specific control (default/horizontal aligned forms only) 511 | echo $this->Form->control('title', [ 512 | 'spacing' => 'mb-6', 513 | ]); 514 | ``` 515 | 516 | To completely disable this behavior, set the `spacing` option to `false`. 517 | 518 | ### Supported controls 519 | 520 | BootstrapUI supports and generates Bootstrap compatible markup for all of CakePHP's default controls. Additionally it 521 | explicitly supports Bootstrap specific markup for the following controls: 522 | 523 | - `color` 524 | - `range` 525 | - `switch` 526 | 527 | ### Container attributes 528 | 529 | Attributes of the outer control container can be changed via the `container` option, cutting the need to use custom 530 | templates for simple changes. The `class` attribute is a special case, its value will be prepended to the existing 531 | list of classes instead of replacing it. 532 | 533 | ```php 534 | echo $this->Form->control('title', [ 535 | 'container' => [ 536 | 'class' => 'my-title-control', 537 | 'data-meta' => 'meta information', 538 | ], 539 | ]); 540 | ``` 541 | 542 | This would generate the following HTML: 543 | 544 | ```html 545 |
546 | 547 | 548 |
549 | ``` 550 | 551 | ### Appending/Prepending content 552 | 553 | Appending/Prepending content to input groups is supported via the `append` and `prepend` options respectively. 554 | 555 | ```php 556 | echo $this->Form->control('email', [ 557 | 'prepend' => '@', 558 | ]); 559 | ``` 560 | 561 | This would generate the following HTML: 562 | 563 | ```html 564 |
565 | 566 |
567 | @ 568 | 569 |
570 |
571 | ``` 572 | 573 | #### Multiple addons 574 | 575 | Multiple addons can be defined as an array for the `append` and `prepend` options: 576 | 577 | ```php 578 | echo $this->Form->control('amount', [ 579 | 'prepend' => ['$', '0.00'], 580 | ]); 581 | ``` 582 | 583 | This would generate the following HTML: 584 | 585 | ```html 586 |
587 | 588 |
589 | $ 590 | 0.00 591 | 592 |
593 |
594 | ``` 595 | 596 | #### Addon options 597 | 598 | Addons support options that apply to the input group container. They can be defined by passing an array for the `append` 599 | and `prepend` options, and adding an array with options as the last entry. 600 | 601 | Options can contain HTML attributes as know from control options, as well as the special `size` option, which 602 | automatically translates to the corresponding input group size class. 603 | 604 | ```php 605 | echo $this->Form->control('amount', [ 606 | 'prepend' => [ 607 | '$', 608 | '0.00', 609 | [ 610 | 'size' => 'lg', 611 | 'class' => 'custom', 612 | 'custom' => 'attribute', 613 | ], 614 | ], 615 | ]); 616 | ``` 617 | 618 | This would generate the following HTML: 619 | 620 | ```html 621 |
622 | 623 |
624 | $ 625 | 0.00 626 | 627 |
628 |
629 | ``` 630 | 631 | ### Inline checkboxes and radio buttons 632 | 633 | [Inline checkboxes/switches and radio buttons](https://getbootstrap.com/docs/5.3/components/forms/#inline) (not to be 634 | confused with inline aligned forms), can be created by setting the `inline` option to `true`. 635 | 636 | Inlined checkboxes/switches and radio buttons will be rendered on the same horizontal row. When using horizontal form 637 | alignment however, only multi-checkboxes will render on the same row! 638 | 639 | ```php 640 | echo $this->Form->control('option_1', [ 641 | 'type' => 'checkbox', 642 | 'inline' => true, 643 | ]); 644 | echo $this->Form->control('option_2', [ 645 | 'type' => 'checkbox', 646 | 'inline' => true, 647 | ]); 648 | ``` 649 | 650 | This would generate the following HTML: 651 | 652 | ```html 653 |
654 | 655 | 656 | 657 |
658 |
659 | 660 | 661 | 662 |
663 | ``` 664 | 665 | ### Switches 666 | 667 | [Switch style checkboxes](https://getbootstrap.com/docs/5.3/forms/checks-radios/#switches) can be created by setting the 668 | `switch` option to `true`. 669 | 670 | ```php 671 | echo $this->Form->control('option', [ 672 | 'type' => 'checkbox', 673 | 'switch' => true, 674 | ]); 675 | ``` 676 | 677 | This would generate the following HTML: 678 | 679 | ```html 680 |
681 | 682 | 683 | 684 |
685 | ``` 686 | 687 | ### Floating labels 688 | 689 | [Floating labels](https://getbootstrap.com/docs/5.3/forms/floating-labels) are supported for `text`, `textarea`, and 690 | (non-`multiple`) `select` controls. They can be enabled via the label's `floating` option: 691 | 692 | ```php 693 | echo $this->Form->control('title', [ 694 | 'label' => [ 695 | 'floating' => true, 696 | ], 697 | ]); 698 | ``` 699 | 700 | This would generate the following HTML: 701 | 702 | ```html 703 |
704 | 705 | 706 |
707 | ``` 708 | 709 | ### Help text 710 | 711 | Bootstrap's [form help text](https://getbootstrap.com/docs/5.3/components/forms/#help-text) is supported via the 712 | `help` option. 713 | 714 | The help text is by default being rendered in between of the control and the validation feedback. 715 | 716 | ```php 717 | echo $this->Form->control('title', [ 718 | 'help' => 'Help text', 719 | ]); 720 | ``` 721 | 722 | This would generate the following HTML: 723 | 724 | ```html 725 |
726 | 727 | 728 | Help text 729 |
730 | ``` 731 | 732 | Attributes can be configured by passing an array for the `help` option, where the text is then defined in the `content` 733 | key: 734 | 735 | ```php 736 | echo $this->Form->control('title', [ 737 | 'help' => [ 738 | 'id' => 'custom-help', 739 | 'class' => 'custom', 740 | 'data-custom' => 'attribute', 741 | 'content' => 'Help text', 742 | ], 743 | ]); 744 | ``` 745 | 746 | This would generate the following HTML: 747 | 748 | ```html 749 |
750 | 751 | 752 | Help text 753 |
754 | ``` 755 | 756 | ### Tooltips 757 | 758 | [Bootstrap tooltips](https://getbootstrap.com/docs/5.3/components/tooltips/) can be added to labels via the `tooltip` 759 | option. The tooltip toggles are by default being rendered as a [Bootstrap icon](https://icons.getbootstrap.com/), which 760 | is being included by default when installing the assets via the `install` command. 761 | 762 | ```php 763 | echo $this->Form->control('title', [ 764 | 'tooltip' => 'Tooltip text', 765 | ]); 766 | ``` 767 | 768 | This would generate the following HTML: 769 | 770 | ```html 771 |
772 | 775 | 776 |
777 | ``` 778 | 779 | If you want to use a different toggle, being it a different Boostrap icon, or maybe a completely different icon 780 | font/library, then you can do this by 781 | [overriding the `tooltip` template](https://book.cakephp.org/5/en/views/helpers/form.html#customizing-the-templates-formhelper-uses) 782 | accordingly, being it globally, per form, or per control: 783 | 784 | ```php 785 | echo $this->Form->control('title', [ 786 | 'tooltip' => 'Tooltip text', 787 | 'templates' => [ 788 | 'tooltip' => 'info', 789 | ], 790 | ]); 791 | ``` 792 | 793 | ### Error feedback style 794 | 795 | BootstrapUI supports two styles of error feedback, the 796 | [regular Bootstrap text feedback](https://getbootstrap.com/docs/5.3/components/forms/#validation), and 797 | [Bootstrap tooltip feedback](https://getbootstrap.com/docs/5.3/components/forms/#tooltips) (not to be confused with 798 | label tooltips that are configured via the `tooltip` option!). 799 | 800 | The style can be configured via the `feedbackStyle` option, either globally, per form, or per control. The supported 801 | styles are: 802 | 803 | - `\BootstrapUI\View\Helper\FormHelper::FEEDBACK_STYLE_DEFAULT` Render error feedback as regular Bootstrap text 804 | feedback. 805 | - `\BootstrapUI\View\Helper\FormHelper::FEEDBACK_STYLE_TOOLTIP` Render error feedback as Bootstrap tooltip feedback 806 | (inline forms are using this style by default). 807 | 808 | Note that using the tooltip error style requires the form group elements to be non-static positioned! The form helper 809 | will automatically add Bootstraps [position utility class](https://getbootstrap.com/docs/5.3/utilities/position/) 810 | `position-relative` to the form group elements when the tooltip error style is enabled. 811 | 812 | If you need different positioning, use either CSS to override the `position` rule on the `.form-group` elements, or use 813 | the `formGroupPosition` option to set your desired position, either globally, per form, or per control. The option 814 | supports the following values: 815 | 816 | - `\BootstrapUI\View\Helper\FormHelper::POSITION_ABSOLUTE` 817 | - `\BootstrapUI\View\Helper\FormHelper::POSITION_FIXED` 818 | - `\BootstrapUI\View\Helper\FormHelper::POSITION_RELATIVE` 819 | - `\BootstrapUI\View\Helper\FormHelper::POSITION_STATIC` 820 | - `\BootstrapUI\View\Helper\FormHelper::POSITION_STICKY` 821 | 822 | ```php 823 | $this->Form->setConfig([ 824 | 'feedbackStyle' => \BootstrapUI\View\Helper\FormHelper::FEEDBACK_STYLE_TOOLTIP, 825 | 'formGroupPosition' => \BootstrapUI\View\Helper\FormHelper::POSITION_ABSOLUTE, 826 | ]); 827 | 828 | // ... 829 | 830 | echo $this->Form->control('title'); 831 | ``` 832 | 833 | With an error on the `title` field, this would generate the following HTML: 834 | 835 | ```html 836 |
837 | 838 | 839 |
Error message
840 |
841 | ``` 842 | 843 | ### Flash Messages / Alerts 844 | 845 | You can set Flash Messages using the default Flash component syntax. Supported types are `success`, `info`, `warning`, 846 | `error`. 847 | 848 | ```php 849 | $this->Flash->success('Your Success Message.'); 850 | ``` 851 | 852 | #### Alert styles 853 | 854 | If you need to set other Bootstrap Alert styles you can do this with: 855 | 856 | ```php 857 | $this->Flash->set('Your Dark Message.', ['params' => ['class' => 'dark']]); 858 | ``` 859 | 860 | Supported styles are `primary`, `secondary`, `light`, `dark`. 861 | 862 | #### Icons 863 | 864 | By default alerts use Bootstrap icons depending on the alert type. The mapped types are `default`, `info`, `warning`, 865 | `error`, and `success`. You can disable/customize icons via the `icon` option/parameter, either globally for the flash 866 | helper, or individually for a single message. 867 | 868 | Message without icon: 869 | 870 | ```php 871 | $this->Flash->success('Message without icon.', [ 872 | 'params' => [ 873 | 'icon' => false, 874 | ], 875 | ]); 876 | ``` 877 | 878 | Use a custom icon: 879 | 880 | ```php 881 | $this->Flash->success('Message with custom icon.', [ 882 | 'params' => [ 883 | 'icon' => 'mic-mute-fill', 884 | ], 885 | ]); 886 | ``` 887 | 888 | Pass icon options (the icon name is optional here, when omitted, the default icon map will be looked up): 889 | 890 | ```php 891 | $this->Flash->success('Message with custom icon options.', [ 892 | 'params' => [ 893 | 'icon' => [ 894 | 'name' => 'mic-mute-fill', 895 | 'size' => '2xl', 896 | 'class' => 'foo bar me-2', 897 | 'data-custom' => 'attribute', 898 | ], 899 | ], 900 | ]); 901 | ``` 902 | 903 | ```html 904 | 905 | ``` 906 | 907 | Use custom HTML: 908 | 909 | ```php 910 | $this->Flash->success('Message with custom icon HTML.', [ 911 | 'params' => [ 912 | 'icon' => 'volume_off', 913 | ], 914 | ]); 915 | ``` 916 | 917 | Disable icons for all flash messages: 918 | 919 | ```php 920 | $this->loadHelper('Flash', [ 921 | 'className' => 'BootstrapUI.Flash', 922 | 'icon' => false, 923 | ]); 924 | ``` 925 | 926 | Set icon options for all flash messages (the default icon map will be used, and the options will be applied to all 927 | icons): 928 | 929 | ```php 930 | $this->loadHelper('Flash', [ 931 | 'className' => 'BootstrapUI.Flash', 932 | 'icon' => [ 933 | 'size' => '2xl', 934 | 'class' => 'foo bar me-2', 935 | 'data-custom' => 'attribute', 936 | ], 937 | ]); 938 | ``` 939 | 940 | Define a custom icon map: 941 | 942 | ```php 943 | $this->loadHelper('Flash', [ 944 | 'className' => 'BootstrapUI.Flash', 945 | 'iconMap' => [ 946 | 'default' => 'info-circle-fill', 947 | 'success' => 'check-circle-fill', 948 | 'error' => 'exclamation-triangle-fill', 949 | 'info' => 'info-circle-fill', 950 | 'warning' => 'exclamation-triangle-fill', 951 | ], 952 | ]); 953 | ``` 954 | 955 | Use a different icon set: 956 | 957 | ```php 958 | $this->Flash->success('Message with different icon set.', [ 959 | 'params' => [ 960 | 'icon' => [ 961 | 'namespace' => 'fas', 962 | 'prefix' => 'fa', 963 | 'name' => 'microphone-slash', 964 | 'size' => '2xl', 965 | ], 966 | ], 967 | ]); 968 | ``` 969 | 970 | ```html 971 | 972 | ``` 973 | 974 | Use a different icon set for all flash messages: 975 | 976 | ```php 977 | $this->loadHelper('Html', [ 978 | 'className' => 'BootstrapUI.Html', 979 | 'iconDefaults' => [ 980 | 'namespace' => 'fas', 981 | 'prefix' => 'fa', 982 | ], 983 | ]); 984 | ``` 985 | 986 | ```php 987 | $this->loadHelper('Flash', [ 988 | 'className' => 'BootstrapUI.Flash', 989 | 'iconMap' => [ 990 | 'default' => 'info-circle', 991 | 'success' => 'check-circle', 992 | 'error' => 'exclamation-triangle', 993 | 'info' => 'info-circle', 994 | 'warning' => 'exclamation-triangle', 995 | ], 996 | ]); 997 | ``` 998 | 999 | ### Badges 1000 | 1001 | By default badges will render as `secondary` theme styled: 1002 | 1003 | ```php 1004 | echo $this->Html->badge('Text'); 1005 | ``` 1006 | 1007 | ```html 1008 | Text 1009 | ``` 1010 | 1011 | #### Background colors 1012 | 1013 | [Background colors](https://getbootstrap.com/docs/5.3/components/badge/#background-colors) can be changed by specifying 1014 | one of the Bootstrap theme color names via the `class` option, the helper will make sure that the correct prefixes 1015 | are being applied: 1016 | 1017 | ```php 1018 | echo $this->Html->badge('Text', [ 1019 | 'class' => 'danger', 1020 | ]); 1021 | ``` 1022 | 1023 | ```html 1024 | Text 1025 | ``` 1026 | 1027 | #### Using a different HTML tag 1028 | 1029 | By default badges are using the `` tag. This can be changed via the `tag` option: 1030 | 1031 | ```php 1032 | echo $this->Html->badge('Text', [ 1033 | 'tag' => 'div', 1034 | ]); 1035 | ``` 1036 | 1037 | ```html 1038 |
Text
1039 | ``` 1040 | 1041 | ### Icons 1042 | 1043 | By default the HTML helper is configured to use [Bootstrap icons](https://icons.getbootstrap.com/). 1044 | 1045 | ```php 1046 | echo $this->Html->icon('mic-mute-fill'); 1047 | ``` 1048 | 1049 | ```html 1050 | 1051 | ``` 1052 | 1053 | #### Sizes 1054 | 1055 | Sizes can be specified via the `size` option, the passed value will automatically be prefixed: 1056 | 1057 | ```php 1058 | echo $this->Html->icon('mic-mute-fill', [ 1059 | 'size' => '2xl', 1060 | ]); 1061 | ``` 1062 | 1063 | ```html 1064 | 1065 | ``` 1066 | 1067 | This plugin ships Bootstrap icon classes for the following sizes that center-align the icon vertically: `2xs`, `xs`, 1068 | `sm`, `lg`, `xl`, and `2xl`, and the following ones that align the icons on the baseline: `1x`, `2x`, `3x`, `4x`, `5x`, 1069 | `6x`, `7x`, `8x`, `9x`, and `10x`. 1070 | 1071 | #### Using a different icon set 1072 | 1073 | You can use a different icon set by configuring the `namespace` and `prefix `options, either per `icon()` call: 1074 | 1075 | ```php 1076 | echo $this->Html->icon('microphone-slash', [ 1077 | 'namespace' => 'fas', 1078 | 'prefix' => 'fa', 1079 | ]); 1080 | ``` 1081 | 1082 | or globally for all usages of `HtmlHelper::icon()` by configuring the HTML helper defaults: 1083 | 1084 | ```php 1085 | $this->loadHelper('Html', [ 1086 | 'className' => 'BootstrapUI.Html', 1087 | 'iconDefaults' => [ 1088 | 'namespace' => 'fas', 1089 | 'prefix' => 'fa', 1090 | ], 1091 | ]); 1092 | ``` 1093 | 1094 | ### Breadcrumbs 1095 | 1096 | The breadcrumbs helper is a drop-in replacement, no additional configuration is available/required. 1097 | 1098 | ```php 1099 | echo $this->Breadcrumbs 1100 | ->add('Home', '/') 1101 | ->add('Articles', '/articles') 1102 | ->add('View') 1103 | ->render(); 1104 | ``` 1105 | 1106 | ```html 1107 | 1114 | ``` 1115 | 1116 | ### Pagination 1117 | 1118 | The paginator helper generates bootstrap compatible/styles markup when using the helper's standard methods, and also 1119 | includes a convenience method that can generate a full set of pagination controls, that is first/previous/next/last as 1120 | well as page number links, all enclosed in a list wrapper. 1121 | 1122 | ```php 1123 | echo $this->Paginator->first(); 1124 | echo $this->Paginator->prev(); 1125 | echo $this->Paginator->numbers(); 1126 | echo $this->Paginator->next(); 1127 | echo $this->Paginator->last(); 1128 | ``` 1129 | 1130 | This would generate the following HTML: 1131 | 1132 | ```html 1133 |
  • 1134 | 1135 | 1136 | 1137 |
  • 1138 |
  • 1139 | 1142 |
  • 1143 |
  • 1144 | 1 1145 |
  • 1146 |
  • 1147 | 2 1148 |
  • 1149 |
  • 1150 | 3 1151 |
  • 1152 |
  • 1153 | 1156 |
  • 1157 |
  • 1158 | 1159 | 1160 | 1161 |
  • 1162 | ``` 1163 | 1164 | #### Configuring the ARIA labels 1165 | 1166 | When using the standard methods you can use the `label` option to pass a custom string to use for 1167 | [the `aria-label` attribute](https://getbootstrap.com/docs/5.3/components/pagination/#working-with-icons): 1168 | 1169 | ```php 1170 | echo $this->Paginator->first('«', ['label' => __('Beginning')]); 1171 | echo $this->Paginator->prev('‹', ['label' => __('Back')]); 1172 | echo $this->Paginator->next('›', ['label' => __('Forward')]); 1173 | echo $this->Paginator->last('»', ['label' => __('End')]); 1174 | ``` 1175 | 1176 | This would generate the following HTML: 1177 | 1178 | ```html 1179 |
  • 1180 | 1181 | 1182 | 1183 |
  • 1184 |
  • 1185 | 1188 |
  • 1189 |
  • 1190 | 1193 |
  • 1194 |
  • 1195 | 1196 | 1197 | 1198 |
  • 1199 | ``` 1200 | 1201 | #### Generating a full set of controls 1202 | 1203 | A full set of pagination controls, that is first/previous/next/last as well as page number links, all enclosed in a list 1204 | wrapper, can be generated using the `links()` method. 1205 | 1206 | By default it renders numbers only: 1207 | 1208 | ```php 1209 | echo $this->Paginator->links(); 1210 | ``` 1211 | 1212 | This would generate the following HTML: 1213 | 1214 | ```html 1215 | 1226 | ``` 1227 | 1228 | ##### Configuring controls 1229 | 1230 | The generated controls can be configured via the `first`, `prev`, `next`, and `last` options, which each can take either 1231 | boolean `true` to generate the control with the helper defaults, a string that is used as the control's text, or an 1232 | array that allows specifying the link text as well as the ARIA label. 1233 | 1234 | The generated controls can be configured via the `first`, `prev`, `next`, and `last` options, which each take either 1235 | boolean `true` to indicate that the control should be generated using the helper defaults, a string that is used as the 1236 | control's text, or an array with `label` and `text` options that determine the ARIA label value and the link text: 1237 | 1238 | ```php 1239 | echo $this->Paginator->links([ 1240 | 'first' => '❮❮', 1241 | 'prev' => true, 1242 | 'next' => true, 1243 | 'last' => [ 1244 | 'label' => 'End', 1245 | 'text' => '❯❯', 1246 | ], 1247 | ]); 1248 | ``` 1249 | 1250 | This would generate the following HTML: 1251 | 1252 | ```html 1253 | 1284 | ``` 1285 | 1286 | ##### Sizing 1287 | 1288 | [The size](https://getbootstrap.com/docs/5.3/components/pagination/#sizing) can be specified via the `size` option: 1289 | 1290 | ```php 1291 | echo $this->Paginator->links([ 1292 | 'size' => 'lg', 1293 | ]); 1294 | ``` 1295 | 1296 | This would generate the following HTML: 1297 | 1298 | ```html 1299 | 1302 | ``` 1303 | 1304 | ### Helper configuration 1305 | 1306 | You can configure each of the helpers by passing in extra parameters when loading them in your `AppView.php`. 1307 | 1308 | Here is an example of changing the `prev` and `next` labels for the Paginator helper. 1309 | 1310 | ```php 1311 | $this->loadHelper('Paginator', [ 1312 | 'className' => 'BootstrapUI.Paginator', 1313 | 'labels' => [ 1314 | 'prev' => 'previous', 1315 | 'next' => 'next', 1316 | ], 1317 | ]); 1318 | ``` 1319 | 1320 | ## Contributing 1321 | 1322 | ### Patches & Features 1323 | 1324 | * Fork 1325 | * Mod, fix 1326 | * Test - this is important, so it's not unintentionally broken 1327 | * Commit - do not mess with license, todo, version, etc. (if you do change any, put them into separate commits that can 1328 | be ignored when pulling) 1329 | * Pull request - bonus point for topic branches 1330 | 1331 | To ensure your PRs are considered for upstream, you MUST follow the CakePHP coding standards. A `pre-commit` 1332 | hook has been included to automatically run the code sniffs for you. From your project's root directory: 1333 | 1334 | ``` 1335 | cp ./contrib/pre-commit .git/hooks/ 1336 | chmod 755 .git/hooks/pre-commit 1337 | ``` 1338 | 1339 | ### Testing 1340 | 1341 | When working on the plugin's code you can run the tests for BootstrapUI by doing the following: 1342 | 1343 | ``` 1344 | composer install 1345 | ./vendor/bin/phpunit 1346 | ``` 1347 | 1348 | ### Bugs & Feedback 1349 | 1350 | https://github.com/friendsofcake/bootstrap-ui/issues 1351 | 1352 | 1353 | [cakephp]:https://cakephp.org/ 1354 | [composer]:https://getcomposer.org/ 1355 | [composer:ignore]:https://getcomposer.org/doc/faqs/should-i-commit-the-dependencies-in-my-vendor-directory.md 1356 | [bs]:https://getbootstrap.com/ 1357 | [npm]:https://www.npmjs.com/ 1358 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friendsofcake/bootstrap-ui", 3 | "description": "Bootstrap front-end framework support for CakePHP", 4 | "type": "cakephp-plugin", 5 | "keywords": ["cakephp", "bootstrap", "front-end"], 6 | "homepage": "http://github.com/friendsofcake/bootstrap-ui", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Jad Bitar", 11 | "homepage": "http://jadb.io", 12 | "role": "Author" 13 | }, 14 | { 15 | "name": "Others", 16 | "homepage": "https://github.com/friendsofcake/bootstrap-ui/graphs/contributors" 17 | } 18 | ], 19 | "require": { 20 | "cakephp/cakephp": "^5.1.4" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "^10.5.5 || ^11.1.3", 24 | "cakephp/bake": "^3.0", 25 | "cakephp/cakephp-codesniffer": "^5.1" 26 | }, 27 | "support": { 28 | "issues": "http://github.com/friendsofcake/bootstrap-ui/issues", 29 | "source": "http://github.com/friendsofcake/bootstrap-ui" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "BootstrapUI\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "BootstrapUI\\Test\\": "tests", 39 | "TestApp\\": "tests/test_app/TestApp" 40 | } 41 | }, 42 | "scripts": { 43 | "check": [ 44 | "@test", 45 | "@cs-check" 46 | ], 47 | "cs-check": "phpcs -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --ignore=comparisons src/ tests/", 48 | "cs-fix": "phpcbf --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --ignore=comparisons src/ tests/", 49 | "test": "phpunit", 50 | "phpstan": "tools/phpstan", 51 | "psalm": "tools/psalm", 52 | "stan": [ 53 | "@phpstan", 54 | "@psalm" 55 | ], 56 | "stan-setup": "phive install" 57 | }, 58 | "config": { 59 | "allow-plugins": { 60 | "dealerdirect/phpcodesniffer-composer-installer": true 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /contrib/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | FILES=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php` 3 | PROJECT=`php -r "echo dirname(dirname(realpath('$0')));"` 4 | 5 | # Determine if a file list is passed 6 | if [ "$#" -eq 1 ] 7 | then 8 | oIFS=$IFS 9 | IFS=' 10 | ' 11 | SFILES="$1" 12 | IFS=$oIFS 13 | fi 14 | SFILES=${SFILES:-$FILES} 15 | 16 | echo "Checking PHP Lint..." 17 | for FILE in $SFILES 18 | do 19 | php -l -d display_errors=0 $PROJECT/$FILE 20 | if [ $? != 0 ] 21 | then 22 | echo "Fix the error before commit." 23 | exit 1 24 | fi 25 | FILES="$FILES $PROJECT/$FILE" 26 | done 27 | 28 | if [ "$SFILES" != "" ] 29 | then 30 | echo "Running PHPCS" 31 | ./vendor/bin/phpcs --standard=vendor/cakephp/cakephp-codesniffer/CakePHP $SFILES 32 | if [ $? != 0 ] 33 | then 34 | echo "PHPCS Errors found; commit aborted." 35 | exit 1 36 | fi 37 | fi 38 | exit $? 39 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootstrap-ui", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "bootstrap": "^5.3.3", 9 | "bootstrap-icons": "^1.11.3" 10 | } 11 | }, 12 | "node_modules/@popperjs/core": { 13 | "version": "2.11.8", 14 | "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", 15 | "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", 16 | "peer": true, 17 | "funding": { 18 | "type": "opencollective", 19 | "url": "https://opencollective.com/popperjs" 20 | } 21 | }, 22 | "node_modules/bootstrap": { 23 | "version": "5.3.3", 24 | "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", 25 | "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", 26 | "funding": [ 27 | { 28 | "type": "github", 29 | "url": "https://github.com/sponsors/twbs" 30 | }, 31 | { 32 | "type": "opencollective", 33 | "url": "https://opencollective.com/bootstrap" 34 | } 35 | ], 36 | "peerDependencies": { 37 | "@popperjs/core": "^2.11.8" 38 | } 39 | }, 40 | "node_modules/bootstrap-icons": { 41 | "version": "1.11.3", 42 | "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", 43 | "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", 44 | "funding": [ 45 | { 46 | "type": "github", 47 | "url": "https://github.com/sponsors/twbs" 48 | }, 49 | { 50 | "type": "opencollective", 51 | "url": "https://opencollective.com/bootstrap" 52 | } 53 | ] 54 | } 55 | }, 56 | "dependencies": { 57 | "@popperjs/core": { 58 | "version": "2.11.8", 59 | "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", 60 | "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", 61 | "peer": true 62 | }, 63 | "bootstrap": { 64 | "version": "5.3.3", 65 | "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", 66 | "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", 67 | "requires": {} 68 | }, 69 | "bootstrap-icons": { 70 | "version": "1.11.3", 71 | "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", 72 | "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "bootstrap": "^5.3.3", 4 | "bootstrap-icons": "^1.11.3" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/BootstrapUIPlugin.php: -------------------------------------------------------------------------------- 1 | add('bootstrap', BootstrapCommand::class) 64 | ->add('bootstrap install', InstallCommand::class) 65 | ->add('bootstrap modify_view', ModifyViewCommand::class) 66 | ->add('bootstrap copy_layouts', CopyLayoutsCommand::class); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Command/BootstrapCommand.php: -------------------------------------------------------------------------------- 1 | commands = $commands; 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | */ 37 | public function execute(Arguments $args, ConsoleIo $io): ?int 38 | { 39 | $io->warning('No command provided. Run `bootstrap --help` to get a list of commands.'); 40 | 41 | return static::CODE_ERROR; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | protected function displayHelp(ConsoleOptionParser $parser, Arguments $args, ConsoleIo $io): void 48 | { 49 | $io->out(Text::wrap($parser->getDescription(), 72), 2); 50 | $io->info('Available Commands:', 2); 51 | 52 | foreach ($this->commands as $command => $class) { 53 | if (substr($command, 0, 10) === 'bootstrap ') { 54 | $io->out("- $command"); 55 | } 56 | } 57 | 58 | $io->out(); 59 | $io->out('To run a command, type `bootstrap command_name [args|options]`'); 60 | $io->out('To get help on a specific command, type `bootstrap command_name --help`', 2); 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser 67 | { 68 | return $parser 69 | ->setDescription( 70 | 'The BootstrapUI console provides commands for installing dependencies ' . 71 | 'and samples, and for modifying your application to use BootstrapUI.', 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Command/CopyLayoutsCommand.php: -------------------------------------------------------------------------------- 1 | info('Copying sample layouts...'); 24 | 25 | $target = $args->getArgument('target'); 26 | if ($target === null) { 27 | $target = $this->_getDefaultTargetPath(); 28 | } 29 | 30 | if (!$this->_copyLayouts($target)) { 31 | $io->error("Sample layouts could not be copied to `$target`."); 32 | $this->abort(); 33 | } 34 | 35 | $io->success("Sample layouts copied successfully to `$target`."); 36 | 37 | return static::CODE_SUCCESS; 38 | } 39 | 40 | /** 41 | * Copies the layouts to the given path. 42 | * 43 | * @param string $targetPath The path where to copy the files to. 44 | * @return bool 45 | */ 46 | protected function _copyLayouts(string $targetPath): bool 47 | { 48 | $source = Plugin::path('BootstrapUI') . 'templates' . DS . 'layout' . DS . 'examples'; 49 | 50 | $filesystem = new Filesystem(); 51 | 52 | return $filesystem->copyDir($source, $targetPath); 53 | } 54 | 55 | /** 56 | * Returns the default layouts target path. 57 | * 58 | * @return string 59 | */ 60 | protected function _getDefaultTargetPath(): string 61 | { 62 | return dirname(APP) . DS . 'templates' . DS . 'layout' . DS . 'TwitterBootstrap' . DS; 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser 69 | { 70 | return $parser 71 | ->setDescription( 72 | 'Copies the sample layouts into the application\'s layout templates folder.', 73 | ) 74 | ->addArgument('target', [ 75 | 'help' => sprintf( 76 | 'The target path into which to copy the layout files. Defaults to `%s`.', 77 | $this->_getDefaultTargetPath(), 78 | ), 79 | 'required' => false, 80 | ]); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Command/InstallCommand.php: -------------------------------------------------------------------------------- 1 | installPackages($args, $io); 26 | $this->refreshAssetBuffer($io); 27 | $this->removePluginAssets($io); 28 | $this->linkPluginAssets($io); 29 | 30 | $io->out(); 31 | $io->success('Installation completed.'); 32 | 33 | return static::CODE_SUCCESS; 34 | } 35 | 36 | /** 37 | * Installs Bootstrap dependencies using NPM. 38 | * 39 | * @param \Cake\Console\Arguments $args The command arguments. 40 | * @param \Cake\Console\ConsoleIo $io The console io. 41 | * @return void 42 | */ 43 | public function installPackages(Arguments $args, ConsoleIo $io): void 44 | { 45 | if (!$this->_isNPMAvailable()) { 46 | $io->error('NPM (https://www.npmjs.com/) is required, but not installed. Aborting.'); 47 | $this->abort(); 48 | } 49 | 50 | $io->info('Clearing `node_modules` folder (this can take a while)...'); 51 | if (!$this->_deleteNodeModules()) { 52 | $io->error('Could not clear `node_modules` folder.'); 53 | $this->abort(); 54 | } 55 | $io->success('Cleared `node_modules` folder.'); 56 | 57 | $io->info('Installing packages...'); 58 | 59 | $output = []; 60 | $return = 0; 61 | $this->_runNPMInstall($output, $return, $io, $args->getOption('latest') === true); 62 | $io->out($output); 63 | 64 | if ($return !== 0) { 65 | $io->error('Package installation failed.'); 66 | $this->abort($return); 67 | } 68 | $io->success('Packages installed successfully.'); 69 | } 70 | 71 | /** 72 | * Extracts assets from node packages into the plugin's webroot. 73 | * 74 | * @param \Cake\Console\ConsoleIo $io The console io. 75 | * @return void 76 | */ 77 | public function refreshAssetBuffer(ConsoleIo $io): void 78 | { 79 | $io->info('Refreshing package asset buffer...'); 80 | if (!$this->_deleteBufferedPackageAssets($io)) { 81 | $io->error('Could not clear all buffered files.'); 82 | $this->abort(); 83 | } else { 84 | $io->success('All buffered files cleared.'); 85 | } 86 | 87 | if (!$this->_bufferPackageAssets($io)) { 88 | $io->error('Could not buffer all files.'); 89 | $this->abort(); 90 | } else { 91 | $io->success('All files buffered.'); 92 | } 93 | } 94 | 95 | /** 96 | * Removes possibly already linked plugin assets from the application's webroot. 97 | * 98 | * @param \Cake\Console\ConsoleIo $io The console io. 99 | * @return void 100 | */ 101 | public function removePluginAssets(ConsoleIo $io): void 102 | { 103 | $io->info('Removing possibly existing plugin assets...'); 104 | 105 | $result = $this->executeCommand(PluginAssetsRemoveCommand::class, ['name' => 'BootstrapUI'], $io); 106 | if ( 107 | $result !== static::CODE_SUCCESS && 108 | $result !== null 109 | ) { 110 | $io->error('Removing plugin assets failed.'); 111 | $this->abort($result); 112 | } 113 | } 114 | 115 | /** 116 | * Links the plugin assets into the application's webroot. 117 | * 118 | * @param \Cake\Console\ConsoleIo $io The console io. 119 | * @return void 120 | */ 121 | public function linkPluginAssets(ConsoleIo $io): void 122 | { 123 | $io->info('Linking plugin assets...'); 124 | 125 | $result = $this->executeCommand(PluginAssetsSymlinkCommand::class, ['name' => 'BootstrapUI'], $io); 126 | if ( 127 | $result !== static::CODE_SUCCESS && 128 | $result !== null 129 | ) { 130 | $io->error('Linking plugin assets failed.'); 131 | $this->abort($result); 132 | } 133 | } 134 | 135 | /** 136 | * Checks whether the NPM command is available. 137 | * 138 | * @return bool 139 | */ 140 | protected function _isNPMAvailable(): bool 141 | { 142 | if ($this->_isWindows()) { 143 | $command = 'where npm'; 144 | } else { 145 | $command = 'which npm'; 146 | } 147 | 148 | return !!`$command`; 149 | } 150 | 151 | /** 152 | * Checks whether the OS in Windows based. 153 | * 154 | * @return bool 155 | */ 156 | protected function _isWindows(): bool 157 | { 158 | return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; 159 | } 160 | 161 | /** 162 | * Deletes the `node_modules` folder. 163 | * 164 | * @return bool 165 | */ 166 | protected function _deleteNodeModules(): bool 167 | { 168 | $filesystem = new Filesystem(); 169 | 170 | return $filesystem->deleteDir(Plugin::path('BootstrapUI') . 'node_modules'); 171 | } 172 | 173 | /** 174 | * Runs the NPM install command. 175 | * 176 | * @param array $output The variable to write the output to. 177 | * @param int $return The variable to write the return status code to. 178 | * @param \Cake\Console\ConsoleIo $io The console io. 179 | * @param bool $useLatest Whether to install the latest minor versions. 180 | * @return void 181 | */ 182 | protected function _runNPMInstall(array &$output, int &$return, ConsoleIo $io, bool $useLatest = false): void 183 | { 184 | $pluginPath = Plugin::path('BootstrapUI'); 185 | if (!$this->_changeWorkingDirectory($pluginPath)) { 186 | $io->error("Could not change into plugin directory `$pluginPath`."); 187 | $this->abort(); 188 | } 189 | 190 | $args = []; 191 | if ($useLatest) { 192 | $args[] = '--package-lock false'; 193 | } 194 | switch ($io->level()) { 195 | case ConsoleIo::QUIET: 196 | if ($this->_isWindows()) { 197 | $null = 'NUL'; 198 | } else { 199 | $null = '/dev/null'; 200 | } 201 | 202 | $args[] = "--silent > $null"; 203 | break; 204 | 205 | case ConsoleIo::VERBOSE: 206 | $args[] = '--verbose'; 207 | break; 208 | } 209 | $args = implode(' ', $args); 210 | 211 | exec("npm install $args", $output, $return); 212 | } 213 | 214 | /** 215 | * Changes the current working directory. 216 | * 217 | * @param string $path The path to change to. 218 | * @return bool 219 | */ 220 | protected function _changeWorkingDirectory(string $path): bool 221 | { 222 | return chdir($path); 223 | } 224 | 225 | /** 226 | * Deletes the buffered package assets. 227 | * 228 | * @param \Cake\Console\ConsoleIo $io The console io. 229 | * @return bool 230 | */ 231 | protected function _deleteBufferedPackageAssets(ConsoleIo $io): bool 232 | { 233 | $result = true; 234 | 235 | foreach ($this->_findBufferedPackageAssets() as $file) { 236 | if ( 237 | is_file($file->getPathname()) && 238 | unlink($file->getPathname()) 239 | ) { 240 | $io->success("`{$file->getFilename()}` successfully deleted.", 1, ConsoleIo::VERBOSE); 241 | } else { 242 | $io->warning("`{$file->getFilename()}` could not be deleted."); 243 | $result = false; 244 | } 245 | } 246 | 247 | return $result; 248 | } 249 | 250 | /** 251 | * Finds the buffered package assets. 252 | * 253 | * @return array<\SplFileInfo> 254 | */ 255 | protected function _findBufferedPackageAssets(): array 256 | { 257 | $filesystem = new Filesystem(); 258 | 259 | $path = Plugin::path('BootstrapUI') . 'webroot'; 260 | $except = '@ 261 | ^.* 262 | (?findRecursive($path, $except) as $file) { 273 | if ($file->isFile()) { 274 | $files[] = $file; 275 | } 276 | } 277 | 278 | return $files; 279 | } 280 | 281 | /** 282 | * Buffers the MPN package assets in the respective folders in the plugin's webroot. 283 | * 284 | * @param \Cake\Console\ConsoleIo $io The console io. 285 | * @return bool 286 | */ 287 | protected function _bufferPackageAssets(ConsoleIo $io): bool 288 | { 289 | $filesystem = new Filesystem(); 290 | 291 | $webrootPath = Plugin::path('BootstrapUI') . 'webroot' . DS; 292 | $cssPath = $webrootPath . 'css' . DS; 293 | $fontPath = $webrootPath . 'font' . DS; 294 | $jsPath = $webrootPath . 'js' . DS; 295 | 296 | $result = true; 297 | foreach ($this->_findPackageAssets() as $file) { 298 | $assetPath = null; 299 | 300 | $matches = []; 301 | $DS = preg_quote(DS, '/'); 302 | if ( 303 | preg_match( 304 | "/{$DS}font{$DS}(?P.+{$DS})?.+\\.(css|woff|woff2)$/", 305 | $file->getPathname(), 306 | $matches, 307 | ) 308 | ) { 309 | $assetPath = $fontPath; 310 | } elseif (preg_match('/\.css(\.map)?$/', $file->getFilename())) { 311 | $assetPath = $cssPath; 312 | } elseif (preg_match('/\.js(\.map)?$/', $file->getFilename())) { 313 | $assetPath = $jsPath; 314 | } 315 | 316 | if ($assetPath === null) { 317 | $io->info("Skipped `{$file->getFilename()}`.", 1, ConsoleIo::VERBOSE); 318 | continue; 319 | } 320 | 321 | if ( 322 | isset($matches['subdirs']) && 323 | $matches['subdirs'] 324 | ) { 325 | $assetPath .= $matches['subdirs']; 326 | } 327 | 328 | $filesystem->mkdir($assetPath); 329 | 330 | if ( 331 | is_file($file->getPathname()) && 332 | copy($file->getPathname(), $assetPath . $file->getFilename()) 333 | ) { 334 | $io->success("`{$file->getFilename()}` successfully copied.", 1, ConsoleIo::VERBOSE); 335 | } else { 336 | $io->warning("`{$file->getFilename()}` could not be copied."); 337 | $result = false; 338 | } 339 | } 340 | 341 | return $result; 342 | } 343 | 344 | /** 345 | * Finds the package assets to buffer. 346 | * 347 | * @return array<\SplFileInfo> 348 | */ 349 | protected function _findPackageAssets(): array 350 | { 351 | $filesystem = new Filesystem(); 352 | 353 | $nodeModulesPath = Plugin::path('BootstrapUI') . 'node_modules' . DS; 354 | $paths = [ 355 | $nodeModulesPath . 'bootstrap/dist', 356 | $nodeModulesPath . 'bootstrap-icons', 357 | ]; 358 | 359 | $files = []; 360 | foreach ($paths as $path) { 361 | /** @var \SplFileInfo $file */ 362 | foreach ($filesystem->findRecursive($path) as $file) { 363 | if ($file->isFile()) { 364 | $files[] = $file; 365 | } 366 | } 367 | } 368 | 369 | return $files; 370 | } 371 | 372 | /** 373 | * @inheritDoc 374 | */ 375 | protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser 376 | { 377 | return $parser 378 | ->setDescription( 379 | 'Installs Bootstrap dependencies and links the assets to the application\'s webroot.', 380 | ) 381 | ->addOption('latest', [ 382 | 'help' => 'To install the latest minor versions of required assets.', 383 | 'required' => false, 384 | 'boolean' => true, 385 | 'short' => 'l', 386 | ]); 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /src/Command/ModifyViewCommand.php: -------------------------------------------------------------------------------- 1 | info('Modifying view...'); 22 | 23 | $file = $args->getArgument('file'); 24 | if ($file === null) { 25 | $file = $this->_getDefaultFilePath(); 26 | } 27 | 28 | if (!$this->_modifyView($file)) { 29 | $io->error("Could not modify `$file`."); 30 | $this->abort(); 31 | } 32 | 33 | $io->success("Modified `$file`."); 34 | 35 | return static::CODE_SUCCESS; 36 | } 37 | 38 | /** 39 | * Modifies the view file at the given path. 40 | * 41 | * @param string $filePath The path of the file to modify. 42 | * @return bool 43 | */ 44 | protected function _modifyView(string $filePath): bool 45 | { 46 | if (!$this->_isFile($filePath)) { 47 | return false; 48 | } 49 | 50 | $content = $this->_readFile($filePath); 51 | if ($content === false) { 52 | return false; 53 | } 54 | 55 | $content = str_replace( 56 | 'use Cake\\View\\View', 57 | 'use BootstrapUI\\View\\UIView', 58 | $content, 59 | ); 60 | $content = str_replace( 61 | 'class AppView extends View', 62 | 'class AppView extends UIView', 63 | $content, 64 | ); 65 | $content = str_replace( 66 | " public function initialize(): void\n {\n", 67 | " public function initialize(): void\n {\n parent::initialize();\n", 68 | $content, 69 | ); 70 | 71 | return $this->_writeFile($filePath, $content); 72 | } 73 | 74 | /** 75 | * Checks whether the given path points to a file. 76 | * 77 | * @param string $filePath The file path. 78 | * @return bool 79 | */ 80 | protected function _isFile(string $filePath): bool 81 | { 82 | return is_file($filePath); 83 | } 84 | 85 | /** 86 | * Reads a files contents. 87 | * 88 | * @param string $filePath The file path. 89 | * @return string|false 90 | */ 91 | protected function _readFile(string $filePath): false|string 92 | { 93 | return file_get_contents($filePath); 94 | } 95 | 96 | /** 97 | * Writes to a file. 98 | * 99 | * @param string $filePath The file path. 100 | * @param string $content The content to write. 101 | * @return bool 102 | */ 103 | protected function _writeFile(string $filePath, string $content): bool 104 | { 105 | return file_put_contents($filePath, $content) !== false; 106 | } 107 | 108 | /** 109 | * Returns the default `AppView.php` file path. 110 | * 111 | * @return string 112 | */ 113 | protected function _getDefaultFilePath(): string 114 | { 115 | return APP . 'View' . DS . 'AppView.php'; 116 | } 117 | 118 | /** 119 | * @inheritDoc 120 | */ 121 | protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser 122 | { 123 | return $parser 124 | ->setDescription( 125 | 'Modifies `AppView.php` to extend this plugin\'s `UIView` class.', 126 | ) 127 | ->addArgument('file', [ 128 | 'help' => sprintf( 129 | 'The path of the `AppView.php` file. Defaults to `%s`.', 130 | $this->_getDefaultFilePath(), 131 | ), 132 | 'required' => false, 133 | ]) 134 | ->setEpilog( 135 | 'Don\'t run this command if you have a already modified the `AppView` class!', 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/View/Helper/BreadcrumbsHelper.php: -------------------------------------------------------------------------------- 1 | 'last', 17 | 'templates' => [ 18 | 'wrapper' => '', 19 | 'item' => '{{title}}', 20 | 'itemWithoutLink' => '{{title}}
    ', 21 | 'separator' => '', 22 | ], 23 | ]; 24 | 25 | /** 26 | * Default attributes for the templates 27 | * 28 | * @var array 29 | */ 30 | protected array $_defaultAttributes = [ 31 | 'class' => [ 32 | 'wrapper' => 'breadcrumb', 33 | 'item' => 'breadcrumb-item', 34 | ], 35 | ]; 36 | 37 | /** 38 | * @inheritDoc 39 | */ 40 | public function render(array $attributes = [], array $separator = []): string 41 | { 42 | $attributes = $this->injectClasses($this->_defaultAttributes['class']['wrapper'], $attributes); 43 | 44 | $this->_markActiveCrumb(); 45 | 46 | return parent::render($attributes, $separator); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function add(array|string $title, array|string|null $url = null, array $options = []) 53 | { 54 | if (is_array($title)) { 55 | $crumbs = []; 56 | foreach ($title as $crumb) { 57 | $options = []; 58 | if (isset($crumb['options'])) { 59 | $options = $crumb['options']; 60 | } 61 | 62 | $crumb['options'] = $this->injectClasses($this->_defaultAttributes['class']['item'], $options); 63 | $crumbs[] = $crumb + ['title' => '', 'url' => null, 'options' => []]; 64 | } 65 | 66 | return parent::add($crumbs); 67 | } 68 | 69 | $options = $this->injectClasses($this->_defaultAttributes['class']['item'], $options); 70 | 71 | return parent::add($title, $url, $options); 72 | } 73 | 74 | /** 75 | * @inheritDoc 76 | */ 77 | public function prepend(array|string $title, array|string|null $url = null, array $options = []) 78 | { 79 | $options = $this->injectClasses($this->_defaultAttributes['class']['item'], $options); 80 | 81 | return parent::prepend($title, $url, $options); 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public function insertAt(int $index, string $title, array|string|null $url = null, array $options = []) 88 | { 89 | $options = $this->injectClasses($this->_defaultAttributes['class']['item'], $options); 90 | 91 | return parent::insertAt($index, $title, $url, $options); 92 | } 93 | 94 | /** 95 | * Marks the active crumb in the current set of crumbs with an 96 | * `active` class and the `aria-current` attribute. 97 | * 98 | * @return void 99 | */ 100 | protected function _markActiveCrumb(): void 101 | { 102 | if (!$this->crumbs) { 103 | return; 104 | } 105 | 106 | $this->_clearActiveCrumb(); 107 | 108 | $key = null; 109 | if ($this->getConfig('ariaCurrent') === 'lastWithLink') { 110 | foreach (array_reverse($this->crumbs, true) as $key => $crumb) { 111 | if (isset($crumb['url'])) { 112 | break; 113 | } 114 | } 115 | } else { 116 | $key = count($this->crumbs) - 1; 117 | } 118 | 119 | if (!$key) { 120 | return; 121 | } 122 | 123 | $this->crumbs[$key]['options'] = $this->injectClasses('active', $this->crumbs[$key]['options']); 124 | 125 | if (isset($this->crumbs[$key]['url'])) { 126 | $this->crumbs[$key]['options']['innerAttrs']['aria-current'] = 'page'; 127 | } else { 128 | $this->crumbs[$key]['options']['aria-current'] = 'page'; 129 | } 130 | } 131 | 132 | /** 133 | * Removes the `active` class and the `aria-current` attribute from 134 | * the active crumb in the current set of crumbs. 135 | * 136 | * @return void 137 | */ 138 | protected function _clearActiveCrumb(): void 139 | { 140 | foreach ($this->crumbs as $key => $crumb) { 141 | $this->crumbs[$key]['options'] = $this->removeClasses('active', $this->crumbs[$key]['options']); 142 | 143 | unset( 144 | $this->crumbs[$key]['options']['innerAttrs']['aria-current'], 145 | $this->crumbs[$key]['options']['aria-current'], 146 | ); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/View/Helper/FlashHelper.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | protected array $_defaultConfig = [ 25 | 'class' => ['alert', 'alert-dismissible', 'fade', 'show', 'd-flex', 'align-items-center'], 26 | 'attributes' => ['role' => 'alert'], 27 | 'icon' => true, 28 | 'iconMap' => [ 29 | 'default' => 'info-circle-fill', 30 | 'success' => 'check-circle-fill', 31 | 'error' => 'exclamation-triangle-fill', 32 | 'info' => 'info-circle-fill', 33 | 'warning' => 'exclamation-triangle-fill', 34 | ], 35 | 'element' => 'BootstrapUI.flash/default', 36 | ]; 37 | 38 | /** 39 | * Similar to the core's FlashHelper used to render the message set in FlashComponent::set(). 40 | * 41 | * If the flash element configured is one of "default", "error", "info", "success" or "warning" 42 | * "BootstrapUI." is prepended to the name so that the element is used from this plugin. 43 | * 44 | * @param string $key The [Flash.]key you are rendering in the view. 45 | * @param array $options Additional options to use for the creation of this flash message. 46 | * Supports the 'params', and 'element' keys that are used in the helper. 47 | * @return string|null Rendered flash message or null if flash key does not exist 48 | * in session. 49 | * @throws \UnexpectedValueException If value for flash settings key is not an array. 50 | */ 51 | public function render(string $key = 'flash', array $options = []): ?string 52 | { 53 | $stack = $this->getView()->getRequest()->getSession()->read("Flash.$key"); 54 | if ($stack === null) { 55 | return null; 56 | } 57 | 58 | if (!is_array($stack)) { 59 | throw new UnexpectedValueException(sprintf( 60 | 'Value for flash setting key "%s" must be an array.', 61 | $key, 62 | )); 63 | } 64 | 65 | if (isset($stack['element'])) { 66 | $stack = [$stack]; 67 | } 68 | 69 | $out = ''; 70 | foreach ($stack as $message) { 71 | /** @var array $message */ 72 | $message = $options + $message; 73 | $message['params'] += $this->_config; 74 | $this->getView()->getRequest()->getSession()->delete("Flash.$key"); 75 | 76 | $element = $message['element']; 77 | if ( 78 | strpos($element, '.') === false && 79 | preg_match('#flash/(default|success|error|info|warning)$#', $element, $matches) 80 | ) { 81 | $class = $matches[1]; 82 | 83 | $icon = $message['params']['icon']; 84 | if ($icon !== false) { 85 | if (!is_array($icon)) { 86 | $icon = ['name' => $icon]; 87 | } 88 | 89 | if ( 90 | !isset($icon['name']) || 91 | $icon['name'] === true 92 | ) { 93 | $iconMap = $this->getConfig('iconMap'); 94 | $mappedIcon = $iconMap[$class] ?? false; 95 | 96 | if (!is_array($mappedIcon)) { 97 | $mappedIcon = ['name' => $mappedIcon]; 98 | } 99 | $icon = $mappedIcon + $icon; 100 | } 101 | 102 | $message['params']['icon'] = $icon['name']; 103 | unset($icon['name']); 104 | 105 | $message['params']['iconOptions'] = $icon + [ 106 | 'size' => 'xl', 107 | 'class' => 'me-2', 108 | ]; 109 | } 110 | 111 | $class = str_replace(['default', 'error'], ['info', 'danger'], $class); 112 | 113 | if (is_array($message['params']['class'])) { 114 | $message['params']['class'][] = 'alert-' . $class; 115 | } 116 | 117 | if ( 118 | is_string($message['params']['class']) && 119 | preg_match('#primary|secondary|light|dark#', $message['params']['class'], $matches) 120 | ) { 121 | $message['params']['class'] = $this->_config['class']; 122 | $message['params']['class'][] = 'alert-' . $matches[0]; 123 | } 124 | 125 | $element = $this->_config['element']; 126 | } 127 | 128 | $out .= $this->_View->element($element, $message); 129 | } 130 | 131 | return $out; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/View/Helper/FormHelper.php: -------------------------------------------------------------------------------- 1 | 139 | '
    {{content}}
    ', 140 | 'errorTooltip' => 141 | '
    {{content}}
    ', 142 | 'label' => 143 | '{{text}}{{tooltip}}', 144 | 'help' => 145 | '{{content}}', 146 | 'tooltip' => 147 | '', 148 | 'formGroupFloatingLabel' => 149 | '{{input}}{{label}}', 150 | 'inputContainer' => 151 | '{{content}}{{help}}', 153 | 'inputContainerError' => 154 | '' . 156 | '{{content}}{{error}}{{help}}', 157 | 'checkboxContainer' => 158 | '{{content}}{{help}}', 161 | 'checkboxContainerError' => 162 | '' . 165 | '{{content}}{{error}}{{help}}', 166 | 'checkboxInlineContainer' => 167 | '' . 169 | '{{content}}{{help}}', 170 | 'checkboxInlineContainerError' => 171 | '{{content}}{{error}}{{help}}', 174 | 'checkboxFormGroup' => 175 | '{{input}}{{label}}', 176 | 'checkboxWrapper' => 177 | '
    {{label}}
    ', 178 | 'checkboxInlineWrapper' => 179 | '
    {{label}}
    ', 180 | 'radioContainer' => 181 | '{{content}}{{help}}', 183 | 'radioContainerError' => 184 | '{{content}}{{error}}{{help}}', 187 | 'radioLabel' => 188 | '{{text}}{{tooltip}}', 189 | 'radioWrapper' => 190 | '
    {{hidden}}{{label}}
    ', 191 | 'radioInlineWrapper' => 192 | '
    {{label}}
    ', 193 | 'staticControl' => 194 | '

    {{content}}

    ', 195 | 'inputGroupContainer' => 196 | '{{prepend}}{{content}}{{append}}', 197 | 'inputGroupText' => 198 | '{{content}}', 199 | 'multicheckboxContainer' => 200 | '{{content}}{{help}}', 202 | 'multicheckboxContainerError' => 203 | '{{content}}{{error}}{{help}}', 206 | 'multicheckboxLabel' => 207 | '{{text}}{{tooltip}}', 208 | 'multicheckboxWrapper' => 209 | '
    {{content}}
    ', 210 | 'multicheckboxTitle' => 211 | '{{text}}', 212 | 'nestingLabel' => 213 | '{{hidden}}{{input}}{{text}}{{tooltip}}', 214 | 'nestingLabelNestedInput' => 215 | '{{hidden}}{{input}}{{text}}{{tooltip}}', 216 | 'submitContainer' => 217 | '{{content}}', 218 | 'errorClass' => 'is-invalid', 219 | ]; 220 | 221 | /** 222 | * Templates set per alignment type 223 | * 224 | * @var array 225 | */ 226 | protected array $_templateSet = [ 227 | 'default' => [ 228 | ], 229 | 'inline' => [ 230 | 'elementWrapper' => 231 | '
    {{content}}
    ', 232 | 'checkboxInlineContainer' => 233 | '' . 234 | '{{content}}{{help}}', 235 | 'checkboxInlineContainerError' => 236 | '{{content}}{{error}}{{help}}', 239 | 'radioContainer' => 240 | '' . 243 | '{{content}}{{help}}', 244 | 'radioContainerError' => 245 | '' . 248 | '{{content}}{{error}}{{help}}', 249 | 'radioLabel' => 250 | '{{text}}{{tooltip}}', 251 | 'multicheckboxContainer' => 252 | '{{content}}{{help}}', 255 | 'multicheckboxContainerError' => 256 | '{{content}}{{error}}{{help}}', 259 | 'multicheckboxLabel' => 260 | '{{text}}{{tooltip}}', 261 | 'multicheckboxWrapper' => 262 | '
    {{content}}
    ', 263 | 'multicheckboxTitle' => 264 | '{{text}}', 265 | ], 266 | 'horizontal' => [ 267 | 'label' => 268 | '{{text}}{{tooltip}}', 269 | 'formGroup' => 270 | '{{label}}
    {{input}}{{error}}{{help}}
    ', 271 | 'formGroupFloatingLabel' => 272 | '
    {{input}}{{label}}{{error}}{{help}}
    ', 273 | 'checkboxFormGroup' => 274 | '
    {{input}}{{label}}{{error}}{{help}}
    ', 275 | 'checkboxInlineFormGroup' => 276 | '
    {{input}}{{label}}
    ', 277 | 'submitContainer' => 278 | '
    {{content}}
    ', 279 | 'inputContainer' => 280 | '' . 281 | '{{content}}', 282 | 'inputContainerError' => 283 | '' . 285 | '{{content}}', 286 | 'checkboxContainer' => 287 | '{{content}}', 288 | 'checkboxContainerError' => 289 | '' . 291 | '{{content}}', 292 | 'radioContainer' => 293 | '' . 295 | '{{content}}', 296 | 'radioContainerError' => 297 | '' . 300 | '{{content}}', 301 | 'radioLabel' => 302 | '{{text}}{{tooltip}}', 303 | 'multicheckboxContainer' => 304 | '' . 306 | '{{content}}', 307 | 'multicheckboxContainerError' => 308 | '' . 311 | '{{content}}', 312 | 'multicheckboxLabel' => 313 | '{{text}}{{tooltip}}', 314 | ], 315 | ]; 316 | 317 | /** 318 | * Default Bootstrap widgets. 319 | * 320 | * @var array 321 | */ 322 | protected array $_widgets = [ 323 | 'button' => 'BootstrapUI\View\Widget\ButtonWidget', 324 | 'datetime' => 'BootstrapUI\View\Widget\DateTimeWidget', 325 | 'file' => ['BootstrapUI\View\Widget\FileWidget', 'label'], 326 | 'select' => 'BootstrapUI\View\Widget\SelectBoxWidget', 327 | 'textarea' => 'BootstrapUI\View\Widget\TextareaWidget', 328 | '_default' => 'BootstrapUI\View\Widget\BasicWidget', 329 | ]; 330 | 331 | /** 332 | * {@inheritDoc} 333 | * 334 | * Additionally to the core form helper options, the following BootstrapUI related options are supported: 335 | * 336 | * - `align` - The default alignment to use for all forms. 337 | * - `grid` - The default grid setup to use for all horizontal forms. 338 | * - `spacing` - The spacing to use for all forms. Can be either a string to define a class that will be 339 | * used for all form alignments, or an array of strings, keyed by the alignment type to define individual 340 | * classes to use per alignment. Set to `false` to disable automatic spacing class usage. 341 | * - `templateSet` - An array of template sets, keyed by the alignment type. 342 | */ 343 | public function __construct(View $View, array $config = []) 344 | { 345 | $this->_defaultConfig = [ 346 | 'align' => static::ALIGN_DEFAULT, 347 | 'errorClass' => version_compare(Configure::version(), '5.2.0', '<') ? 'is-invalid' : null, 348 | 'grid' => [ 349 | static::GRID_COLUMN_ONE => 2, 350 | static::GRID_COLUMN_TWO => 10, 351 | ], 352 | 'spacing' => null, 353 | 'templates' => $this->_templates + $this->_defaultConfig['templates'], 354 | ] + $this->_defaultConfig; 355 | 356 | if (isset($this->_defaultConfig['templateSet'])) { 357 | $templateSet = Hash::merge($this->_templateSet, $this->_defaultConfig['templateSet']); 358 | } else { 359 | $templateSet = $this->_templateSet; 360 | } 361 | $this->_defaultConfig['templateSet'] = $templateSet; 362 | 363 | $this->_defaultWidgets = $this->_widgets + $this->_defaultWidgets; 364 | 365 | parent::__construct($View, $config); 366 | } 367 | 368 | /** 369 | * {@inheritDoc} 370 | * 371 | * Additionally to the core form helper create options, the following BootstrapUI related options are supported: 372 | * 373 | * - `spacing` - The spacing to use for the form. Can be either a string to define a class, or boolean `false` to 374 | * disable automatic spacing class usage. 375 | */ 376 | public function create(mixed $context = null, array $options = []): string 377 | { 378 | $options += [ 379 | 'class' => null, 380 | 'align' => null, 381 | 'templates' => [], 382 | 'spacing' => null, 383 | ]; 384 | 385 | // This is only for backwards compatibility with CakePHP < 5.2 386 | if ($this->getConfig('errorClass')) { 387 | $this->setConfig('templates.errorClass', $this->getConfig('errorClass')); 388 | } 389 | 390 | return parent::create($context, $this->_processFormOptions($options)); 391 | } 392 | 393 | /** 394 | * @inheritDoc 395 | */ 396 | public function label(string $fieldName, ?string $text = null, array $options = []): string 397 | { 398 | unset($options['floating']); 399 | 400 | return parent::label($fieldName, $text, $options); 401 | } 402 | 403 | /** 404 | * @inheritDoc 405 | */ 406 | public function submit(?string $caption = null, array $options = []): string 407 | { 408 | $options += [ 409 | 'class' => 'primary', 410 | ]; 411 | $options = $this->applyButtonClasses($options); 412 | $options = $this->_containerOptions(null, $options); 413 | 414 | $result = parent::submit($caption, $options); 415 | 416 | return $this->_postProcessElement($result, null, $options); 417 | } 418 | 419 | /** 420 | * @inheritDoc 421 | */ 422 | public function select(string $fieldName, iterable $options = [], array $attributes = []): string 423 | { 424 | $attributes['injectFormControl'] = false; 425 | $attributes = $this->injectClasses('form-select', $attributes); 426 | 427 | return parent::select($fieldName, $options, $attributes); 428 | } 429 | 430 | /** 431 | * {@inheritDoc} 432 | * 433 | * Additionally to the core form helper options, the following BootstrapUI related options are supported: 434 | * 435 | * - `container` - An array of container attributes, with `class` being a special case, prepending the value to 436 | * the existing list of classes instead of replacing them. 437 | * - `append` - Append addon to input. 438 | * - `prepend` - Prepend addon to input. 439 | * - `inline` - Boolean for generating inline checkbox/radio. 440 | * - `switch` - Boolean for generating switch style checkboxes. 441 | * - `help` - Help text to include in the input container. 442 | * - `tooltip` - Tooltip text to include in the control's label. 443 | * - `feedbackStyle` - The feedback style to use, `default`, or `tooltip` (will cause `formGroupPosition` to be set 444 | * to `relative` unless explicitly configured otherwise). 445 | * - `formGroupPosition` - CSS positioning of form groups, `absolute`, `fixed`, `relative`, `static`, or `sticky`. 446 | * - `spacing` - The spacing to use for the control. Can be either a string to define a class, or boolean `false` 447 | * to disable automatic spacing class usage. 448 | */ 449 | public function control(string $fieldName, array $options = []): string 450 | { 451 | $options += [ 452 | 'feedbackStyle' => null, 453 | 'formGroupPosition' => null, 454 | 'prepend' => null, 455 | 'append' => null, 456 | 'inline' => null, 457 | 'nestedInput' => false, 458 | 'switch' => null, 459 | 'type' => null, 460 | 'label' => null, 461 | 'error' => null, 462 | 'required' => null, 463 | 'options' => null, 464 | 'help' => null, 465 | 'tooltip' => null, 466 | 'templates' => [], 467 | 'templateVars' => [], 468 | 'labelOptions' => true, 469 | 'container' => null, 470 | 'spacing' => null, 471 | ]; 472 | $options = $this->_parseOptions($fieldName, $options); 473 | 474 | $newTemplates = $options['templates']; 475 | if ($newTemplates) { 476 | $this->templater()->push(); 477 | $templateMethod = is_string($options['templates']) ? 'load' : 'add'; 478 | $this->templater()->{$templateMethod}($options['templates']); 479 | $options['templates'] = []; 480 | } 481 | 482 | switch ($options['type']) { 483 | case 'checkbox': 484 | case 'radio': 485 | case 'select': 486 | case 'range': 487 | $function = '_' . $options['type'] . 'Options'; 488 | $options = $this->{$function}($fieldName, $options); 489 | break; 490 | 491 | default: 492 | $options = $this->_labelOptions($fieldName, $options); 493 | break; 494 | } 495 | 496 | $options = $this->_spacingOptions($fieldName, $options); 497 | $options = $this->_containerOptions($fieldName, $options); 498 | $options = $this->_feedbackStyleOptions($fieldName, $options); 499 | $options = $this->_ariaOptions($fieldName, $options); 500 | $options = $this->_placeholderOptions($fieldName, $options); 501 | $options = $this->_helpOptions($fieldName, $options); 502 | $options = $this->_tooltipOptions($fieldName, $options); 503 | 504 | if ( 505 | isset($options['append']) || 506 | isset($options['prepend']) 507 | ) { 508 | $options['injectErrorClass'] = $this->getConfig('templates.errorClass'); 509 | } 510 | 511 | unset( 512 | $options['formGroupPosition'], 513 | $options['feedbackStyle'], 514 | $options['spacing'], 515 | $options['inline'], 516 | $options['nestedInput'], 517 | $options['switch'], 518 | ); 519 | 520 | $result = parent::control($fieldName, $options); 521 | 522 | $result = $this->_postProcessElement($result, $fieldName, $options); 523 | 524 | if ($newTemplates) { 525 | $this->templater()->pop(); 526 | } 527 | 528 | return $result; 529 | } 530 | 531 | /** 532 | * Modify options and templates based on spacing. 533 | * 534 | * @param string $fieldName Field name. 535 | * @param array $options Options. See `$options` argument of `control()` method. 536 | * @return array 537 | */ 538 | protected function _spacingOptions(string $fieldName, array $options): array 539 | { 540 | if (!isset($options['spacing'])) { 541 | $options['spacing'] = $this->_spacing; 542 | } 543 | 544 | if ($options['spacing'] === false) { 545 | return $options; 546 | } 547 | 548 | if ($this->_align !== static::ALIGN_INLINE) { 549 | $options['templates'] += [ 550 | 'multicheckboxWrapper' => sprintf( 551 | $this->templater()->getConfig('multicheckboxWrapper'), 552 | $options['spacing'], 553 | ), 554 | ]; 555 | } 556 | 557 | return $options; 558 | } 559 | 560 | /** 561 | * Modify the options for container templates. 562 | * 563 | * @param string|null $fieldName Field name. 564 | * @param array $options Options. See `$options` argument of `control()` method. 565 | * @return array 566 | */ 567 | protected function _containerOptions(?string $fieldName, array $options): array 568 | { 569 | if ( 570 | $this->_align !== static::ALIGN_INLINE && 571 | isset($options['type']) && 572 | isset($options['spacing']) && 573 | $options['spacing'] !== false 574 | ) { 575 | $options['container'] = $this->injectClasses($options['spacing'], (array)($options['container'] ?? [])); 576 | } 577 | 578 | if ( 579 | $this->_align !== static::ALIGN_HORIZONTAL && 580 | isset($options['label']['floating']) && 581 | $options['label']['floating'] 582 | ) { 583 | $options['container'] = $this->injectClasses('form-floating', (array)($options['container'] ?? [])); 584 | } 585 | 586 | $containerOptions = $options['container'] ?? []; 587 | unset($options['container']); 588 | 589 | if (isset($containerOptions['class'])) { 590 | $options['templateVars']['containerClass'] = $containerOptions['class'] . ' '; 591 | unset($containerOptions['class']); 592 | } 593 | if (!empty($containerOptions)) { 594 | $options['templateVars']['containerAttrs'] = $this->templater()->formatAttributes($containerOptions); 595 | } 596 | 597 | return $options; 598 | } 599 | 600 | /** 601 | * Modify options for checkbox controls. 602 | * 603 | * @param string $fieldName Field name. 604 | * @param array $options Options. See `$options` argument of `control()` method. 605 | * @return array 606 | */ 607 | protected function _checkboxOptions(string $fieldName, array $options): array 608 | { 609 | if ($options['label'] !== false) { 610 | $options['label'] = $this->injectClasses('form-check-label', (array)$options['label']); 611 | } 612 | 613 | if ($this->_align === static::ALIGN_HORIZONTAL) { 614 | $options['inline'] = false; 615 | } 616 | 617 | if ( 618 | $options['inline'] || 619 | $this->_align === static::ALIGN_INLINE 620 | ) { 621 | $checkboxContainer = $this->templater()->get('checkboxInlineContainer'); 622 | $checkboxContainerError = $this->templater()->get('checkboxInlineContainerError'); 623 | 624 | $options['templates']['checkboxContainer'] = $checkboxContainer; 625 | $options['templates']['checkboxContainerError'] = $checkboxContainerError; 626 | } 627 | 628 | if ($options['nestedInput']) { 629 | $options['templates']['nestingLabel'] = $this->templater()->get('nestingLabelNestedInput'); 630 | } 631 | 632 | if ($options['switch']) { 633 | $options['templateVars']['variant'] = ' form-switch'; 634 | } 635 | 636 | return $options; 637 | } 638 | 639 | /** 640 | * Modify options for radio controls. 641 | * 642 | * @param string $fieldName Field name. 643 | * @param array $options Options. See `$options` argument of `control()` method. 644 | * @return array 645 | */ 646 | protected function _radioOptions(string $fieldName, array $options): array 647 | { 648 | $options = $this->_labelOptions($fieldName, $options); 649 | 650 | $options = $this->injectClasses('form-check-input', $options); 651 | 652 | $groupId = 653 | $options['templateVars']['groupId'] = 654 | $this->_domId($fieldName . '-group-label'); 655 | 656 | if ($options['label'] !== false) { 657 | $options['label']['templateVars']['groupId'] = $groupId; 658 | $options['label']['id'] = $groupId; 659 | } 660 | 661 | if ($options['label'] !== false) { 662 | $labelClasses = []; 663 | if ($this->_align !== static::ALIGN_INLINE) { 664 | $labelClasses[] = 'd-block'; 665 | } 666 | if ($this->_align === static::ALIGN_HORIZONTAL) { 667 | $labelClasses[] = 'pt-0'; 668 | } 669 | if ($labelClasses) { 670 | $options['label'] = $this->injectClasses($labelClasses, (array)$options['label']); 671 | } 672 | } 673 | 674 | $options['templates']['label'] = $this->templater()->get('radioLabel'); 675 | 676 | if ( 677 | $options['inline'] || 678 | $this->_align === static::ALIGN_INLINE 679 | ) { 680 | $options['templates']['radioWrapper'] = $this->templater()->get('radioInlineWrapper'); 681 | } 682 | 683 | if ($options['nestedInput']) { 684 | $options['templates']['nestingLabel'] = $this->templater()->get('nestingLabelNestedInput'); 685 | } 686 | 687 | return $options; 688 | } 689 | 690 | /** 691 | * Modify options for select controls. 692 | * 693 | * @param string $fieldName Field name. 694 | * @param array $options Options. See `$options` argument of `control()` method. 695 | * @return array 696 | */ 697 | protected function _selectOptions(string $fieldName, array $options): array 698 | { 699 | $options = $this->_labelOptions($fieldName, $options); 700 | 701 | $labelClasses = []; 702 | 703 | if (isset($options['multiple']) && $options['multiple'] === 'checkbox') { 704 | $options['type'] = 'multicheckbox'; 705 | 706 | $groupId = 707 | $options['templateVars']['groupId'] = 708 | $this->_domId($fieldName . '-group-label'); 709 | 710 | if ($options['label'] !== false) { 711 | $options['label']['templateVars']['groupId'] = $groupId; 712 | $options['label']['id'] = $groupId; 713 | } 714 | 715 | if ($options['label'] !== false) { 716 | if ($this->_align !== static::ALIGN_INLINE) { 717 | $labelClasses[] = 'd-block'; 718 | } 719 | if ($this->_align === static::ALIGN_HORIZONTAL) { 720 | $labelClasses[] = 'pt-0'; 721 | } 722 | } 723 | 724 | $options['templates']['label'] = $this->templater()->get('multicheckboxLabel'); 725 | 726 | $options = $this->injectClasses('form-check-input', $options); 727 | 728 | if ( 729 | $options['inline'] || 730 | $this->_align === static::ALIGN_INLINE 731 | ) { 732 | $wrapper = $this->templater()->get('checkboxInlineWrapper'); 733 | $options['templates']['checkboxWrapper'] = $wrapper; 734 | } 735 | 736 | if ($options['nestedInput']) { 737 | $options['templates']['nestingLabel'] = $this->templater()->get('nestingLabelNestedInput'); 738 | } 739 | 740 | if ($options['switch']) { 741 | $options['templateVars']['variant'] = ' form-switch'; 742 | } 743 | } 744 | 745 | if ( 746 | $this->_align === static::ALIGN_INLINE && 747 | $options['label'] !== false && 748 | !$options['label']['floating'] 749 | ) { 750 | $labelClasses[] = 'visually-hidden'; 751 | } 752 | 753 | if ($labelClasses) { 754 | $options['label'] = $this->injectClasses($labelClasses, (array)$options['label']); 755 | } 756 | 757 | return $options; 758 | } 759 | 760 | /** 761 | * Modify options for range controls. 762 | * 763 | * @param string $fieldName Field name. 764 | * @param array $options Options. See `$options` argument of `control()` method. 765 | * @return array 766 | */ 767 | protected function _rangeOptions(string $fieldName, array $options): array 768 | { 769 | $options = $this->_labelOptions($fieldName, $options); 770 | $options['injectFormControl'] = false; 771 | 772 | if ( 773 | $options['label'] !== false && 774 | $this->_align === static::ALIGN_HORIZONTAL 775 | ) { 776 | $options['label'] = $this->injectClasses('pt-0', (array)$options['label']); 777 | } 778 | 779 | return $this->injectClasses('form-range', $options); 780 | } 781 | 782 | /** 783 | * Modify the options for labels. 784 | * 785 | * @param string|null $fieldName Field name. 786 | * @param array $options Options. See `$options` argument of `control()` method. 787 | * @return array 788 | */ 789 | protected function _labelOptions(?string $fieldName, array $options): array 790 | { 791 | if ($options['label'] !== false) { 792 | $options['label'] = (array)$options['label'] + [ 793 | 'floating' => false, 794 | ]; 795 | 796 | $labelClasses = []; 797 | if ($options['label']['floating']) { 798 | $options['templates']['formGroup'] = $this->templater()->get('formGroupFloatingLabel'); 799 | } 800 | 801 | if ( 802 | $this->_align !== static::ALIGN_HORIZONTAL && 803 | !$options['label']['floating'] 804 | ) { 805 | $labelClasses[] = 'form-label'; 806 | } 807 | 808 | if ($this->_align === static::ALIGN_HORIZONTAL) { 809 | if (!$options['label']['floating']) { 810 | $size = $this->_gridClass(static::GRID_COLUMN_ONE); 811 | $labelClasses[] = "col-form-label $size"; 812 | } else { 813 | $labelClasses[] = 'ps-4'; 814 | } 815 | } 816 | 817 | if ( 818 | $this->_align === static::ALIGN_INLINE && 819 | !$options['label']['floating'] 820 | ) { 821 | $labelClasses[] = 'visually-hidden'; 822 | } 823 | 824 | if ($labelClasses) { 825 | $options['label'] = $this->injectClasses($labelClasses, (array)$options['label']); 826 | } 827 | } 828 | 829 | return $options; 830 | } 831 | 832 | /** 833 | * Modify templates based on error style. 834 | * 835 | * @param string $fieldName Field name. 836 | * @param array $options Options. See `$options` argument of `control()` method. 837 | * @return array 838 | */ 839 | protected function _feedbackStyleOptions(string $fieldName, array $options): array 840 | { 841 | $formGroupPosition = $options['formGroupPosition'] ?: $this->getConfig('formGroupPosition'); 842 | $feedbackStyle = $options['feedbackStyle'] ?: $this->getConfig('feedbackStyle'); 843 | 844 | if ( 845 | $this->_align === static::ALIGN_INLINE && 846 | $feedbackStyle === null 847 | ) { 848 | $feedbackStyle = static::FEEDBACK_STYLE_TOOLTIP; 849 | } 850 | 851 | if ($feedbackStyle === static::FEEDBACK_STYLE_TOOLTIP) { 852 | $options['templates']['error'] = $this->templater()->get('errorTooltip'); 853 | } 854 | 855 | if ( 856 | $formGroupPosition === null && 857 | $feedbackStyle === static::FEEDBACK_STYLE_TOOLTIP 858 | ) { 859 | $formGroupPosition = static::POSITION_RELATIVE; 860 | } 861 | 862 | if ($formGroupPosition !== null) { 863 | $options['templateVars']['formGroupPosition'] = 'position-' . $formGroupPosition . ' '; 864 | } 865 | 866 | return $options; 867 | } 868 | 869 | /** 870 | * Modify options for aria attributes. 871 | * 872 | * @param string $fieldName Field name. 873 | * @param array $options Options. See `$options` argument of `control()` method. 874 | * @return array 875 | */ 876 | protected function _ariaOptions(string $fieldName, array $options): array 877 | { 878 | if ( 879 | $options['type'] === 'hidden' || 880 | ($options['type'] === 'select' && isset($options['multiple']) && $options['multiple'] === 'checkbox') || 881 | ( 882 | isset($options['aria-describedby']) && 883 | isset($options['aria-invalid']) 884 | ) 885 | ) { 886 | return $options; 887 | } 888 | 889 | $isError = 890 | $options['error'] !== false && 891 | $this->isFieldError($fieldName); 892 | 893 | $describedByIds = []; 894 | 895 | if ($isError) { 896 | $describedByIds[] = $this->_domId($fieldName . '-error'); 897 | } 898 | 899 | if ($options['help']) { 900 | if ( 901 | is_array($options['help']) && 902 | isset($options['help']['id']) 903 | ) { 904 | $descriptorId = $options['help']['id']; 905 | } else { 906 | $descriptorId = $this->_domId($fieldName . '-help'); 907 | } 908 | 909 | $describedByIds[] = $descriptorId; 910 | } 911 | 912 | if ($describedByIds) { 913 | $options['aria-describedby'] = $describedByIds; 914 | } 915 | 916 | return $options; 917 | } 918 | 919 | /** 920 | * Modify options for placeholders. 921 | * 922 | * @param string $fieldName Field name. 923 | * @param array $options Options. See `$options` argument of `control()` method. 924 | * @return array 925 | */ 926 | protected function _placeholderOptions(string $fieldName, array $options): array 927 | { 928 | if ( 929 | !isset($options['placeholder']) && 930 | isset($options['label']['floating']) && 931 | $options['label']['floating'] && 932 | in_array($options['type'], ['text', 'textarea'], true) 933 | ) { 934 | if (isset($options['label']['text'])) { 935 | $options['placeholder'] = $options['label']['text']; 936 | } else { 937 | $text = $fieldName; 938 | if (strpos($text, '.') !== false) { 939 | $fieldElements = explode('.', $text); 940 | $text = array_pop($fieldElements); 941 | } 942 | if (substr($text, -3) === '_id') { 943 | $text = substr($text, 0, -3); 944 | } 945 | 946 | $options['placeholder'] = __(Inflector::humanize(Inflector::underscore($text))); 947 | } 948 | } 949 | 950 | return $options; 951 | } 952 | 953 | /** 954 | * Modify options for control's help. 955 | * 956 | * @param string $fieldName Field name. 957 | * @param array $options Options. See `$options` argument of `control()` method. 958 | * @return array 959 | */ 960 | protected function _helpOptions(string $fieldName, array $options): array 961 | { 962 | if ($options['help'] === null) { 963 | return $options; 964 | } 965 | 966 | if (!is_array($options['help'])) { 967 | $options['help'] = [ 968 | 'content' => $options['help'], 969 | ]; 970 | } 971 | 972 | if (!isset($options['help']['id'])) { 973 | $options['help']['id'] = $this->_domId($fieldName . '-help'); 974 | } 975 | 976 | $helpClasses = ['form-text']; 977 | if ($this->_align === static::ALIGN_INLINE) { 978 | $helpClasses[] = 'visually-hidden'; 979 | } 980 | 981 | $options['help'] = $this->injectClasses($helpClasses, $options['help']); 982 | 983 | $options['help'] = $this->templater()->format('help', [ 984 | 'content' => $options['help']['content'], 985 | 'attrs' => $this->templater()->formatAttributes($options['help'], ['content']), 986 | ]); 987 | 988 | return $options; 989 | } 990 | 991 | /** 992 | * Modify options for control's tooltip. 993 | * 994 | * @param string $fieldName Field name. 995 | * @param array $options Options. See `$options` argument of `control()` method. 996 | * @return array 997 | */ 998 | protected function _tooltipOptions(string $fieldName, array $options): array 999 | { 1000 | if ( 1001 | $options['tooltip'] && 1002 | $options['label'] !== false && 1003 | !($options['label']['floating'] ?? false) 1004 | ) { 1005 | $tooltip = $this->templater()->format( 1006 | 'tooltip', 1007 | ['content' => $options['tooltip']], 1008 | ); 1009 | $options['label']['templateVars']['tooltip'] = ' ' . $tooltip; 1010 | } 1011 | unset($options['tooltip']); 1012 | 1013 | return $options; 1014 | } 1015 | 1016 | /** 1017 | * Post processes a generated form element. 1018 | * 1019 | * @param string $html The form element HTML. 1020 | * @param string|null $fieldName The field name. 1021 | * @param array $options The element generation options (see `$options` argument for `button()`, `submit()`, and 1022 | * `control()`). 1023 | * @return string 1024 | * @see button() 1025 | * @see submit() 1026 | * @see control() 1027 | */ 1028 | protected function _postProcessElement(string $html, ?string $fieldName, array $options): string 1029 | { 1030 | if ($this->_align === static::ALIGN_INLINE) { 1031 | $html = $this->templater()->format('elementWrapper', [ 1032 | 'content' => $html, 1033 | ]); 1034 | } 1035 | 1036 | return $html; 1037 | } 1038 | 1039 | /** 1040 | * @inheritDoc 1041 | */ 1042 | public function checkbox(string $fieldName, array $options = []): array|string 1043 | { 1044 | $options = $this->injectClasses('form-check-input', $options); 1045 | 1046 | return parent::checkbox($fieldName, $options); 1047 | } 1048 | 1049 | /** 1050 | * @inheritDoc 1051 | */ 1052 | public function radio(string $fieldName, iterable $options = [], array $attributes = []): string 1053 | { 1054 | $attributes = $this->multiInputAttributes($attributes); 1055 | 1056 | return parent::radio($fieldName, $options, $attributes); 1057 | } 1058 | 1059 | /** 1060 | * @inheritDoc 1061 | */ 1062 | public function multiCheckbox(string $fieldName, iterable $options, array $attributes = []): string 1063 | { 1064 | $attributes = $this->multiInputAttributes($attributes); 1065 | 1066 | return parent::multiCheckbox($fieldName, $options, $attributes); 1067 | } 1068 | 1069 | /** 1070 | * Set options for radio and multi checkbox inputs. 1071 | * 1072 | * @param array $attributes Attributes 1073 | * @return array 1074 | */ 1075 | protected function multiInputAttributes(array $attributes): array 1076 | { 1077 | $classPrefix = 'form-check'; 1078 | 1079 | $attributes += ['label' => true]; 1080 | $attributes = $this->injectClasses($classPrefix . '-input', $attributes); 1081 | 1082 | if ($attributes['label'] === true) { 1083 | $attributes['label'] = []; 1084 | } 1085 | if ($attributes['label'] !== false) { 1086 | $attributes['label'] = $this->injectClasses($classPrefix . '-label', $attributes['label']); 1087 | } 1088 | 1089 | return $attributes; 1090 | } 1091 | 1092 | /** 1093 | * Creates a color input. 1094 | * 1095 | * @param string $fieldName The field name. 1096 | * @param array $options Array of options or HTML attributes. 1097 | * @return string 1098 | */ 1099 | public function color(string $fieldName, array $options = []): string 1100 | { 1101 | $options['injectFormControl'] = false; 1102 | $options = $this->injectClasses('form-control form-control-color', $options); 1103 | 1104 | return $this->text($fieldName, ['type' => 'color'] + $options); 1105 | } 1106 | 1107 | /** 1108 | * @inheritDoc 1109 | */ 1110 | public function end(array $secureAttributes = []): string 1111 | { 1112 | $this->_clearFormState(); 1113 | 1114 | return parent::end($secureAttributes); 1115 | } 1116 | 1117 | /** 1118 | * Used to place plain text next to label within a form. 1119 | * 1120 | * ### Options: 1121 | * 1122 | * - `hiddenField` - boolean to indicate if you want value for field included 1123 | * in a hidden input. Defaults to true. 1124 | * 1125 | * @param string $fieldName Name of a field, like this "modelname.fieldname" 1126 | * @param array $options Array of HTML attributes. 1127 | * @return string An HTML text input element. 1128 | */ 1129 | public function staticControl(string $fieldName, array $options = []): string 1130 | { 1131 | $options += [ 1132 | 'escape' => true, 1133 | 'required' => false, 1134 | 'secure' => true, 1135 | 'hiddenField' => true, 1136 | ]; 1137 | 1138 | $secure = $options['secure']; 1139 | $hiddenField = $options['hiddenField']; 1140 | unset($options['secure'], $options['hiddenField']); 1141 | 1142 | $options = $this->_initInputField( 1143 | $fieldName, 1144 | ['secure' => static::SECURE_SKIP] + $options, 1145 | ); 1146 | 1147 | $content = $options['escape'] ? h($options['val']) : $options['val']; 1148 | $static = $this->formatTemplate('staticControl', [ 1149 | 'content' => $content, 1150 | ]); 1151 | 1152 | if (!$hiddenField) { 1153 | return $static; 1154 | } 1155 | 1156 | if ($secure === true && $this->formProtector) { 1157 | /** @psalm-suppress InternalMethod */ 1158 | $this->formProtector->addField( 1159 | $options['name'], 1160 | true, 1161 | (string)$options['val'], 1162 | ); 1163 | } 1164 | 1165 | $options['type'] = 'hidden'; 1166 | 1167 | return $static . $this->widget('hidden', $options); 1168 | } 1169 | 1170 | /** 1171 | * @inheritDoc 1172 | */ 1173 | protected function _getInput(string $fieldName, array $options): array|string 1174 | { 1175 | unset($options['help']); 1176 | 1177 | return parent::_getInput($fieldName, $options); 1178 | } 1179 | 1180 | /** 1181 | * @inheritDoc 1182 | */ 1183 | protected function _groupTemplate(array $options): string 1184 | { 1185 | $groupTemplate = $options['options']['type'] . 'FormGroup'; 1186 | if (!$this->templater()->get($groupTemplate)) { 1187 | $groupTemplate = 'formGroup'; 1188 | } 1189 | 1190 | return $this->templater()->format($groupTemplate, [ 1191 | 'input' => $options['input'] ?? [], 1192 | 'label' => $options['label'], 1193 | 'error' => $options['error'], 1194 | 'templateVars' => $options['options']['templateVars'] ?? [], 1195 | 'help' => $options['options']['help'], 1196 | ]); 1197 | } 1198 | 1199 | /** 1200 | * @inheritDoc 1201 | */ 1202 | protected function _inputContainerTemplate(array $options): string 1203 | { 1204 | $inputContainerTemplate = $options['options']['type'] . 'Container' . $options['errorSuffix']; 1205 | if (!$this->templater()->get($inputContainerTemplate)) { 1206 | $inputContainerTemplate = 'inputContainer' . $options['errorSuffix']; 1207 | } 1208 | 1209 | return $this->templater()->format($inputContainerTemplate, [ 1210 | 'content' => $options['content'], 1211 | 'error' => $options['error'], 1212 | 'required' => $options['options']['required'] ? ' required' : '', 1213 | 'type' => $options['options']['type'], 1214 | 'templateVars' => $options['options']['templateVars'] ?? [], 1215 | 'help' => $options['options']['help'], 1216 | ]); 1217 | } 1218 | 1219 | /** 1220 | * @inheritDoc 1221 | */ 1222 | protected function _parseOptions(string $fieldName, array $options): array 1223 | { 1224 | $options = parent::_parseOptions($fieldName, $options); 1225 | $options += ['id' => $this->_domId($fieldName)]; 1226 | if (is_string($options['label'])) { 1227 | $options['label'] = ['text' => $options['label']]; 1228 | } 1229 | 1230 | return $options; 1231 | } 1232 | 1233 | /** 1234 | * Processes form creation options. 1235 | * 1236 | * Handles per-form scoped tasks like form alignment detection/switching. 1237 | * 1238 | * @param array $options Options. 1239 | * @return array Modified options. 1240 | */ 1241 | protected function _processFormOptions(array $options): array 1242 | { 1243 | if (!$options['align']) { 1244 | $options['align'] = $this->_detectFormAlignment($options); 1245 | } 1246 | 1247 | if (is_array($options['align'])) { 1248 | $this->_grid = $options['align']; 1249 | $options['align'] = static::ALIGN_HORIZONTAL; 1250 | } elseif ($options['align'] === static::ALIGN_HORIZONTAL) { 1251 | $this->_grid = $this->getConfig('grid'); 1252 | } 1253 | 1254 | if (!in_array($options['align'], static::ALIGN_TYPES)) { 1255 | throw new InvalidArgumentException( 1256 | 'Invalid valid for `align` option. Valid values are: ' . implode(', ', static::ALIGN_TYPES), 1257 | ); 1258 | } 1259 | 1260 | $this->_align = $options['align']; 1261 | 1262 | unset($options['align']); 1263 | 1264 | if (!isset($options['spacing'])) { 1265 | $options['spacing'] = $this->_getSpacingForAlignment($this->_align); 1266 | } 1267 | $this->_spacing = $options['spacing']; 1268 | unset($options['spacing']); 1269 | 1270 | $templates = $this->_config['templateSet'][$this->_align]; 1271 | if (is_string($options['templates'])) { 1272 | $options['templates'] = (new PhpConfig())->read($options['templates']); 1273 | } 1274 | 1275 | if ($this->_align === static::ALIGN_DEFAULT) { 1276 | $options['templates'] += $templates; 1277 | 1278 | return $options; 1279 | } 1280 | 1281 | if ($this->_align === static::ALIGN_INLINE) { 1282 | $options = $this->injectClasses( 1283 | [ 1284 | 'row', 1285 | $this->_spacing, 1286 | 'align-items-center', 1287 | ], 1288 | $options, 1289 | ); 1290 | $options['templates'] += $templates; 1291 | 1292 | return $options; 1293 | } 1294 | 1295 | $templates['label'] = sprintf( 1296 | $templates['label'], 1297 | $this->_gridClass(static::GRID_COLUMN_ONE), 1298 | ); 1299 | $templates['radioLabel'] = sprintf( 1300 | $templates['radioLabel'], 1301 | $this->_gridClass(static::GRID_COLUMN_ONE), 1302 | ); 1303 | $templates['multicheckboxLabel'] = sprintf( 1304 | $templates['multicheckboxLabel'], 1305 | $this->_gridClass(static::GRID_COLUMN_ONE), 1306 | ); 1307 | $templates['formGroup'] = sprintf( 1308 | $templates['formGroup'], 1309 | $this->_gridClass(static::GRID_COLUMN_TWO), 1310 | ); 1311 | 1312 | $offsetGridClass = implode(' ', [ 1313 | $this->_gridClass(static::GRID_COLUMN_ONE, true), 1314 | $this->_gridClass(static::GRID_COLUMN_TWO), 1315 | ]); 1316 | $containers = [ 1317 | 'checkboxFormGroup', 1318 | 'checkboxInlineFormGroup', 1319 | 'formGroupFloatingLabel', 1320 | 'submitContainer', 1321 | ]; 1322 | foreach ($containers as $value) { 1323 | $templates[$value] = sprintf($templates[$value], $offsetGridClass); 1324 | } 1325 | 1326 | $options['templates'] += $templates; 1327 | 1328 | return $options; 1329 | } 1330 | 1331 | /** 1332 | * Returns a Bootstrap grid class (i.e. `col-md-2`). 1333 | * 1334 | * @param int $columnIndex The zero-based column index. 1335 | * @param bool $offset If true, will append `offset-` to the class. 1336 | * @return string Classes. 1337 | */ 1338 | protected function _gridClass(int $columnIndex, bool $offset = false): string 1339 | { 1340 | if ($this->_grid === null) { 1341 | return ''; 1342 | } 1343 | 1344 | $class = 'col-%s-'; 1345 | if ($offset) { 1346 | $class = 'offset-%s-'; 1347 | } 1348 | 1349 | if (isset($this->_grid[$columnIndex])) { 1350 | return sprintf($class, 'md') . $this->_grid[$columnIndex]; 1351 | } 1352 | 1353 | $classes = []; 1354 | foreach ($this->_grid as $screen => $positions) { 1355 | if (isset($positions[$columnIndex])) { 1356 | array_push($classes, sprintf($class, $screen) . $positions[$columnIndex]); 1357 | } 1358 | } 1359 | 1360 | return implode(' ', $classes); 1361 | } 1362 | 1363 | /** 1364 | * Detects the form alignment when possible. 1365 | * 1366 | * @param array $options Options. 1367 | * @return string Form alignment type. One of `default`, `horizontal` or `inline`. 1368 | */ 1369 | protected function _detectFormAlignment(array $options): string 1370 | { 1371 | foreach ([static::ALIGN_HORIZONTAL, static::ALIGN_INLINE] as $align) { 1372 | if ($this->checkClasses('form-' . $align, (array)$options['class'])) { 1373 | return $align; 1374 | } 1375 | } 1376 | 1377 | return $this->getConfig('align'); 1378 | } 1379 | 1380 | /** 1381 | * Returns the spacing class for the given alignment. 1382 | * 1383 | * If no spacing classes have been explicitly configured via the helper's `spacing` option, this method will by 1384 | * default return `g-3` for inline alignment, and `mb-3` for horizontal and default alignments. 1385 | * 1386 | * May return `false` to indicate that no spacing should be used. 1387 | * 1388 | * @param string $align The alignment type for which to retrieve the spacing class. 1389 | * @return string|false 1390 | */ 1391 | protected function _getSpacingForAlignment(string $align): string|false 1392 | { 1393 | $spacing = $this->getConfig('spacing'); 1394 | 1395 | if ($spacing === false) { 1396 | return false; 1397 | } 1398 | 1399 | if ( 1400 | $spacing !== null && 1401 | !is_array($spacing) 1402 | ) { 1403 | $spacing = [ 1404 | static::ALIGN_DEFAULT => $spacing, 1405 | static::ALIGN_HORIZONTAL => $spacing, 1406 | static::ALIGN_INLINE => $spacing, 1407 | ]; 1408 | } 1409 | $spacing = (array)$spacing + [ 1410 | static::ALIGN_DEFAULT => 'mb-3', 1411 | static::ALIGN_HORIZONTAL => 'mb-3', 1412 | static::ALIGN_INLINE => 'g-3', 1413 | ]; 1414 | 1415 | return $spacing[$align]; 1416 | } 1417 | 1418 | /** 1419 | * Clears per-form scoped state. 1420 | * 1421 | * @return void 1422 | */ 1423 | protected function _clearFormState(): void 1424 | { 1425 | $this->_align = 1426 | $this->_grid = 1427 | $this->_spacing = 1428 | null; 1429 | } 1430 | } 1431 | -------------------------------------------------------------------------------- /src/View/Helper/HtmlHelper.php: -------------------------------------------------------------------------------- 1 | -lg` will be added. Default null. 24 | * 25 | * @param \Cake\View\View $View The View this helper is being attached to. 26 | * @param array $config Configuration settings for the helper. 27 | */ 28 | public function __construct(View $View, array $config = []) 29 | { 30 | $this->_defaultConfig['iconDefaults'] = [ 31 | 'tag' => 'i', 32 | 'namespace' => 'bi', 33 | 'prefix' => 'bi', 34 | 'size' => null, 35 | ]; 36 | 37 | parent::__construct($View, $config); 38 | } 39 | 40 | /** 41 | * Returns Bootstrap badge markup. By default, uses ``. 42 | * 43 | * ### Options 44 | * 45 | * - `tag`: The HTML tag to use for the badge. Default `span`. 46 | * 47 | * @param string $text Text to show in badge. 48 | * @param array $options Additional options and HTML attributes. 49 | * @return string HTML badge markup. 50 | */ 51 | public function badge(string $text, array $options = []): string 52 | { 53 | $options += ['tag' => 'span']; 54 | $tag = $options['tag']; 55 | unset($options['tag']); 56 | 57 | $allClasses = $this->genAllClassNames('text-bg'); 58 | 59 | if ($this->hasAnyClass($allClasses, $options)) { 60 | $options = $this->injectClasses('badge', $options); 61 | } else { 62 | $options = $this->injectClasses(['badge', 'secondary'], $options); 63 | } 64 | 65 | $classes = $this->renameClasses('text-bg', $options); 66 | 67 | return $this->tag($tag, $text, $classes); 68 | } 69 | 70 | /** 71 | * Returns bootstrap icon markup. By default, uses `` tag and the bootstrap icon set. 72 | * 73 | * ### Options 74 | * 75 | * - `tag`: The HTML tag to use for the icon. Default `i`. 76 | * - `namespace`: Common class name for the icon set. Default `bi`. 77 | * - `prefix`: Prefix for class names. Default `bi`. 78 | * - `size`: Size class will be generated based of this. For e.g. if you use 79 | * size `lg` class '-lg` will be added. Default null. 80 | * 81 | * You can use `iconDefaults` option for the helper to set default values 82 | * for above options. 83 | * 84 | * @param string $name Name of icon (i.e. `search`, `exclamation`, etc.). 85 | * @param array $options Additional options and HTML attributes. 86 | * @return string HTML icon markup. 87 | */ 88 | public function icon(string $name, array $options = []): string 89 | { 90 | $options += $this->getConfig('iconDefaults') + [ 91 | 'class' => null, 92 | ]; 93 | 94 | $classes = [$options['namespace'], $options['prefix'] . '-' . $name]; 95 | if (!empty($options['size'])) { 96 | $classes[] = $options['prefix'] . '-' . $options['size']; 97 | } 98 | $options = $this->injectClasses($classes, $options); 99 | 100 | return $this->formatTemplate('tag', [ 101 | 'tag' => $options['tag'], 102 | 'attrs' => $this->templater()->formatAttributes( 103 | $options, 104 | ['tag', 'namespace', 'prefix', 'size'], 105 | ), 106 | ]); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/View/Helper/OptionsAwareTrait.php: -------------------------------------------------------------------------------- 1 | genAllClassNames(Element::BTN); 20 | 21 | if ($this->hasAnyClass($allClasses, $data)) { 22 | $data = $this->injectClasses(Element::BTN, $data); 23 | } else { 24 | $data = $this->injectClasses([Element::BTN, Classes::SECONDARY], $data); 25 | } 26 | 27 | return $this->renameClasses(Element::BTN, $data); 28 | } 29 | 30 | /** 31 | * Renames any CSS classes found in the options. 32 | * 33 | * @param string $element UI element to which the classname is applied. 34 | * @param array $options An array of HTML attributes and options. 35 | * @return array An array of HTML attributes and options. 36 | */ 37 | public function renameClasses(string $element, array $options): array 38 | { 39 | $options += ['class' => []]; 40 | 41 | $options['class'] = $this->_toClassArray($options['class']); 42 | $classes = []; 43 | foreach ($options['class'] as $name) { 44 | $classes[] = in_array($name, Classes::values()) 45 | ? $this->genClassName($element, $name) 46 | : $name; 47 | } 48 | $options['class'] = trim(implode(' ', $classes)); 49 | 50 | return $options; 51 | } 52 | 53 | /** 54 | * Checks if `$options['class']` contains any one of the class names. 55 | * 56 | * @param array|string $classes Name of class(es) to check. 57 | * @param array $options An array of HTML attributes and options. 58 | * @return bool True if any one of the class names was found. 59 | */ 60 | public function hasAnyClass(array|string $classes, array $options): bool 61 | { 62 | $options += ['class' => []]; 63 | 64 | $options['class'] = $this->_toClassArray($options['class']); 65 | $classes = $this->_toClassArray($classes); 66 | 67 | foreach ($classes as $class) { 68 | if (in_array($class, $options['class'])) { 69 | return true; 70 | } 71 | } 72 | 73 | return false; 74 | } 75 | 76 | /** 77 | * Injects classes into `$options['class']` when they don't already exist. If a class is defined 78 | * in `$options['skip']` then it will not be injected. This method removes `$options['skip']` before 79 | * returning. 80 | * 81 | * @param array|string $classes Name of class(es) to inject. 82 | * @param array $options An array of HTML attributes and options. 83 | * @return array An array of HTML attributes and options. 84 | */ 85 | public function injectClasses(array|string $classes, array $options): array 86 | { 87 | $options += ['class' => [], 'skip' => []]; 88 | 89 | $options['class'] = $this->_toClassArray($options['class']); 90 | $options['skip'] = $this->_toClassArray($options['skip']); 91 | $classes = $this->_toClassArray($classes); 92 | 93 | foreach ($classes as $class) { 94 | if ( 95 | !in_array($class, $options['class']) && 96 | !in_array($class, $options['skip']) 97 | ) { 98 | array_push($options['class'], $class); 99 | } 100 | } 101 | 102 | unset($options['skip']); 103 | $options['class'] = trim(implode(' ', $options['class'])); 104 | 105 | return $options; 106 | } 107 | 108 | /** 109 | * Removes classes from `$options['class']`. 110 | * 111 | * @param array|string $classes Name of class(es) to remove. 112 | * @param array $options An array of HTML attributes and options. 113 | * @return array An array of HTML attributes and options. 114 | */ 115 | public function removeClasses(array|string $classes, array $options): array 116 | { 117 | $options += ['class' => []]; 118 | 119 | $options['class'] = $this->_toClassArray($options['class']); 120 | $classes = $this->_toClassArray($classes); 121 | 122 | foreach ($classes as $class) { 123 | $indices = array_keys($options['class'], $class); 124 | foreach ($indices as $index) { 125 | unset($options['class'][$index]); 126 | } 127 | } 128 | 129 | $options['class'] = trim(implode(' ', $options['class'])); 130 | 131 | return $options; 132 | } 133 | 134 | /** 135 | * Checks if `$classes` are part of the `$options['class']`. 136 | * 137 | * @param array|string $classes Name of class(es) to check. 138 | * @param array $options An array of HTML attributes and options. 139 | * @return bool False if one or more class(es) do not exist. 140 | */ 141 | public function checkClasses(array|string $classes, array $options): bool 142 | { 143 | if (empty($options['class'])) { 144 | return false; 145 | } 146 | 147 | $options['class'] = $this->_toClassArray($options['class']); 148 | $classes = $this->_toClassArray($classes); 149 | 150 | foreach ($classes as $class) { 151 | if (!in_array($class, $options['class'])) { 152 | return false; 153 | } 154 | } 155 | 156 | return true; 157 | } 158 | 159 | /** 160 | * Normalizes class strings/arrays. 161 | * 162 | * @param mixed $mixed One or more classes. 163 | * @return array Classes as array. 164 | */ 165 | protected function _toClassArray(mixed $mixed): array 166 | { 167 | if ($mixed === null) { 168 | return []; 169 | } 170 | if (!is_array($mixed)) { 171 | $mixed = explode(' ', $mixed); 172 | } 173 | 174 | return $mixed; 175 | } 176 | 177 | /** 178 | * Generates the classname of the given element 179 | * 180 | * @param string $element UI element to which the class can be applied (e.g. btn). 181 | * @param string $class CSS class, which can be applied to the element. 182 | * @return string|bool String of generated class, false if element/class not in list. 183 | */ 184 | public function genClassName(string $element, string $class): bool|string 185 | { 186 | if (!in_array($element, Element::values())) { 187 | return false; 188 | } 189 | 190 | if (!in_array($class, Classes::values())) { 191 | return false; 192 | } 193 | 194 | return $element . '-' . $class; 195 | } 196 | 197 | /** 198 | * Generates a list of all classnames of a element 199 | * 200 | * @param string $element UI element 201 | * @return array Array of all generated and raw styles 202 | */ 203 | public function genAllClassNames(string $element): array 204 | { 205 | $classes = []; 206 | foreach (Classes::values() as $class) { 207 | $classes[] = $this->genClassName($element, $class); 208 | } 209 | 210 | if ($element === Element::BTN) { 211 | foreach (Classes::values() as $class) { 212 | $classes[] = $this->genClassName(Element::BTN_OUTLINE, $class); 213 | } 214 | } 215 | 216 | return array_merge(Classes::values(), $classes); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/View/Helper/PaginatorHelper.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected array $_allowedSizes = ['sm', 'lg']; 18 | 19 | /** 20 | * Label defaults. 21 | * 22 | * @var array 23 | */ 24 | protected array $_labels = [ 25 | 'first' => [ 26 | 'label' => 'First', 27 | 'text' => '«', 28 | ], 29 | 'last' => [ 30 | 'label' => 'Last', 31 | 'text' => '»', 32 | ], 33 | 'prev' => [ 34 | 'label' => 'Previous', 35 | 'text' => '‹', 36 | ], 37 | 'next' => [ 38 | 'label' => 'Next', 39 | 'text' => '›', 40 | ], 41 | ]; 42 | 43 | /** 44 | * Constructor. Overridden to merge passed args with URL options. 45 | * 46 | * @param \Cake\View\View $View The View this helper is being attached to. 47 | * @param array $config Configuration settings for the helper. 48 | */ 49 | public function __construct(View $View, array $config = []) 50 | { 51 | $this->_defaultConfig['templates'] = [ 52 | 'nextActive' => 53 | '
  • ' . 54 | '
  • ', 56 | 'nextDisabled' => 57 | '
  • ' . 58 | '' . 59 | '
  • ', 60 | 'prevActive' => 61 | '
  • ' . 62 | '
  • ', 64 | 'prevDisabled' => 65 | '
  • ' . 66 | '' . 67 | '
  • ', 68 | 'current' => 69 | '
  • ' . 70 | '{{text}}
  • ', 71 | 'first' => 72 | '
  • ' . 73 | '
  • ', 74 | 'last' => 75 | '
  • ' . 76 | '
  • ', 77 | 'number' => 78 | '
  • {{text}}
  • ', 79 | ] + $this->_defaultConfig['templates']; 80 | 81 | parent::__construct($View, $config + [ 82 | 'labels' => $this->_labels, 83 | ]); 84 | } 85 | 86 | /** 87 | * {@inheritDoc} 88 | * 89 | * This methods supports the following options additionally to the ones supported by the core: 90 | * 91 | * - `label` The text to use for the ARIA label property. 92 | * - `templates` An array of templates, or template file name containing the templates you'd like to use when 93 | * generating the link for first page. This method uses the `first` template. 94 | */ 95 | public function first(string|int $first = '«', array $options = []): string 96 | { 97 | $options = $this->_templateOptions('first', $options); 98 | 99 | $templater = $this->templater(); 100 | $templater->push(); 101 | $templateMethod = is_string($options['templates']) ? 'load' : 'add'; 102 | $templater->{$templateMethod}($options['templates']); 103 | 104 | $out = parent::first($first, $options); 105 | 106 | $templater->pop(); 107 | 108 | return $out; 109 | } 110 | 111 | /** 112 | * {@inheritDoc} 113 | * 114 | * This methods supports the following options additionally to the ones supported by the core: 115 | * 116 | * - `label` The text to use for the ARIA label property. 117 | * - `templates` An array of templates, or template file name containing the templates you'd like to use when 118 | * generating the link for last page. This method uses the `last` template. 119 | */ 120 | public function last(string|int $last = '»', array $options = []): string 121 | { 122 | $options = $this->_templateOptions('last', $options); 123 | 124 | $templater = $this->templater(); 125 | $templater->push(); 126 | $templateMethod = is_string($options['templates']) ? 'load' : 'add'; 127 | $templater->{$templateMethod}($options['templates']); 128 | 129 | $out = parent::last($last, $options); 130 | 131 | $templater->pop(); 132 | 133 | return $out; 134 | } 135 | 136 | /** 137 | * {@inheritDoc} 138 | * 139 | * This methods supports the following options additionally to the ones supported by the core: 140 | * 141 | * - `label` The text to use for the ARIA label property. 142 | */ 143 | public function prev(string $title = '‹', array $options = []): string 144 | { 145 | $options = $this->_templateOptions('prev', $options); 146 | 147 | return parent::prev($title, $options); 148 | } 149 | 150 | /** 151 | * {@inheritDoc} 152 | * 153 | * This methods supports the following options additionally to the ones supported by the core: 154 | * 155 | * - `label` The text to use for the ARIA label property. 156 | */ 157 | public function next(string $title = '›', array $options = []): string 158 | { 159 | $options = $this->_templateOptions('next', $options); 160 | 161 | return parent::next($title, $options); 162 | } 163 | 164 | /** 165 | * Returns a set of numbers for the paged result set, wrapped in a ul. 166 | * 167 | * In addition to the numbers, the method can also generate previous/next and first/last 168 | * links using additional options as shown below which are not available in 169 | * CakePHP core's PaginatorHelper::numbers(). It also wraps the numbers into a ul tag. 170 | * 171 | * ### Options 172 | * 173 | * - `first` If set generates "first" link. Can be `true`, a string, or an array. 174 | * - `prev` If set generates "previous" link. Can be `true`, a string, or an array. 175 | * - `next` If set generates "next" link. Can be `true`, a string, or an array. 176 | * - `last` If set generates "last" link. Can be `true`, a string, or an array. 177 | * - `size` Used to control sizing class added to UL tag. For eg. 178 | * using `'size' => 'lg'` would add class `pagination-lg` to UL tag. 179 | * - `escape` Whether to escape the link text. Defaults to `true`. 180 | * 181 | * @param array $options Options for the numbers. 182 | * @return string|false Pagination controls markup, or `false` in case of an invalid `size` option. 183 | * @link http://book.cakephp.org/3.0/en/views/helpers/paginator.html#creating-page-number-links 184 | */ 185 | public function links(array $options = []): string|false 186 | { 187 | $class = 'pagination'; 188 | 189 | $options += [ 190 | 'class' => $class, 191 | 'after' => '', 192 | 'size' => null, 193 | 'escape' => true, 194 | ]; 195 | 196 | $escape = $options['escape']; 197 | unset($options['escape']); 198 | 199 | $options['class'] = implode(' ', (array)$options['class']); 200 | 201 | if (!empty($options['size'])) { 202 | if (!in_array($options['size'], $this->_allowedSizes)) { 203 | return false; 204 | } 205 | $options['class'] .= " {$class}-{$options['size']}"; 206 | } 207 | 208 | $options += [ 209 | 'before' => '
      ', 210 | ]; 211 | 212 | unset($options['class'], $options['size']); 213 | 214 | if (isset($options['first'])) { 215 | $options = $this->_labelOptions('first', $options); 216 | $options['before'] .= $this->first($options['first']['text'], [ 217 | 'label' => $options['first']['label'], 218 | 'escape' => $escape, 219 | ]); 220 | unset($options['first']); 221 | } 222 | 223 | if (isset($options['prev'])) { 224 | $options = $this->_labelOptions('prev', $options); 225 | $options['before'] .= $this->prev($options['prev']['text'], [ 226 | 'label' => $options['prev']['label'], 227 | 'escape' => $escape, 228 | ]); 229 | unset($options['prev']); 230 | } 231 | 232 | if (isset($options['next'])) { 233 | $options = $this->_labelOptions('next', $options); 234 | $options['after'] = $this->next($options['next']['text'], [ 235 | 'label' => $options['next']['label'], 236 | 'escape' => $escape, 237 | ]); 238 | unset($options['next']); 239 | } 240 | 241 | if (isset($options['last'])) { 242 | $options = $this->_labelOptions('last', $options); 243 | $options['after'] .= $this->last($options['last']['text'], [ 244 | 'label' => $options['last']['label'], 245 | 'escape' => $escape, 246 | ]); 247 | unset($options['last']); 248 | } 249 | 250 | $options['after'] .= '
    '; 251 | 252 | return parent::numbers($options); 253 | } 254 | 255 | /** 256 | * Prepares label options. 257 | * 258 | * @param string $name The name of the control for which to prepare the label options. 259 | * @param array $options The array containing the label option. 260 | * @return array 261 | */ 262 | protected function _labelOptions(string $name, array $options): array 263 | { 264 | if ($options[$name] === true) { 265 | $options[$name] = $this->getConfig("labels.$name"); 266 | } 267 | if (!is_array($options[$name])) { 268 | $options[$name] = [ 269 | 'text' => $options[$name], 270 | ]; 271 | } 272 | $options[$name] += $this->_labels[$name]; 273 | 274 | return $options; 275 | } 276 | 277 | /** 278 | * Prepares template options. 279 | * 280 | * @param string $name The name of the control for which to prepare the template options. 281 | * @param array $options The array containing the template option. 282 | * @return array 283 | */ 284 | protected function _templateOptions(string $name, array $options): array 285 | { 286 | $options += [ 287 | 'label' => $this->getConfig("labels.{$name}.label"), 288 | 'templates' => [], 289 | ]; 290 | $label = $options['label']; 291 | unset($options['label']); 292 | 293 | $options['templates'] += [ 294 | "{$name}" => $this->getConfig("templates.{$name}", ''), 295 | "{$name}Active" => $this->getConfig("templates.{$name}Active", ''), 296 | "{$name}Disabled" => $this->getConfig("templates.{$name}Disabled", ''), 297 | ]; 298 | 299 | $options['templates']["{$name}"] = 300 | str_replace('{{label}}', h($label), $options['templates']["{$name}"]); 301 | $options['templates']["{$name}Active"] = 302 | str_replace('{{label}}', h($label), $options['templates']["{$name}Active"]); 303 | $options['templates']["{$name}Disabled"] = 304 | str_replace('{{label}}', h($label), $options['templates']["{$name}Disabled"]); 305 | 306 | return $options; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/View/Helper/Types/Classes.php: -------------------------------------------------------------------------------- 1 | getConstants(); 24 | 25 | return array_values($elements); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/View/Helper/Types/TypeInterface.php: -------------------------------------------------------------------------------- 1 | "value1", 1 => "value2"] 18 | * 19 | * @return array An array of all constants 20 | */ 21 | public static function values(): array; 22 | } 23 | -------------------------------------------------------------------------------- /src/View/UIView.php: -------------------------------------------------------------------------------- 1 | initializeUI(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/View/UIViewTrait.php: -------------------------------------------------------------------------------- 1 | layout === 'default' 27 | ) { 28 | $this->layout = 'BootstrapUI.default'; 29 | } elseif (isset($options['layout']) && is_string($options['layout'])) { 30 | $this->layout = $options['layout']; 31 | } 32 | 33 | $helpers = [ 34 | 'Html' => ['className' => 'BootstrapUI.Html'], 35 | 'Form' => ['className' => 'BootstrapUI.Form'], 36 | 'Flash' => ['className' => 'BootstrapUI.Flash'], 37 | 'Paginator' => ['className' => 'BootstrapUI.Paginator'], 38 | 'Breadcrumbs' => ['className' => 'BootstrapUI.Breadcrumbs'], 39 | ]; 40 | 41 | $this->helpers = array_merge($helpers, $this->helpers); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/View/Widget/BasicWidget.php: -------------------------------------------------------------------------------- 1 | _withInputGroup($data, $context); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/View/Widget/ButtonWidget.php: -------------------------------------------------------------------------------- 1 | applyButtonClasses($data); 24 | 25 | return parent::render($data, $context); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/View/Widget/DateTimeWidget.php: -------------------------------------------------------------------------------- 1 | _withInputGroup($data, $context); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/View/Widget/FileWidget.php: -------------------------------------------------------------------------------- 1 | _label = $label; 34 | 35 | parent::__construct($templates); 36 | } 37 | 38 | /** 39 | * Render a file upload form widget. 40 | * 41 | * Data supports the following keys: 42 | * 43 | * - `name` - Set the input name. 44 | * - `escape` - Set to false to disable HTML escaping. 45 | * 46 | * All other keys will be converted into HTML attributes. 47 | * Unlike other input objects the `val` property will be specifically 48 | * ignored. 49 | * 50 | * @param array $data The data to build a file input with. 51 | * @param \Cake\View\Form\ContextInterface $context The current form context. 52 | * @return string HTML elements. 53 | */ 54 | public function render(array $data, ContextInterface $context): string 55 | { 56 | return $this->_withInputGroup($data, $context); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/View/Widget/InputGroupTrait.php: -------------------------------------------------------------------------------- 1 | null, 33 | 'prepend' => null, 34 | 'append' => null, 35 | 'injectFormControl' => true, 36 | 'injectErrorClass' => null, 37 | 'input' => null, 38 | 'templateVars' => [], 39 | ]; 40 | 41 | if ($data['injectFormControl'] && $data['type'] !== 'hidden') { 42 | $data = $this->injectClasses('form-control', $data); 43 | } 44 | 45 | $prepend = $data['prepend']; 46 | $append = $data['append']; 47 | $errorClass = $data['injectErrorClass']; 48 | unset($data['append'], $data['prepend'], $data['injectFormControl'], $data['injectErrorClass']); 49 | 50 | if (isset($data['input'])) { 51 | $input = $data['input']; 52 | unset($data['input']); 53 | } else { 54 | $input = parent::render($data, $context); 55 | } 56 | 57 | $attrs = []; 58 | 59 | if ($prepend) { 60 | $prepend = $this->_checkForOptions($prepend); 61 | $attrs = $this->_processOptions($prepend, $attrs); 62 | $prepend = $this->_addon($prepend['content'], $data); 63 | } 64 | 65 | if ($append) { 66 | $append = $this->_checkForOptions($append); 67 | $attrs = $this->_processOptions($append, $attrs); 68 | $append = $this->_addon($append['content'], $data); 69 | } 70 | 71 | if ($prepend || $append) { 72 | if ( 73 | $errorClass && 74 | $context->hasError($data['fieldName']) 75 | ) { 76 | $attrs['class'][] = $errorClass; 77 | } 78 | 79 | $input = $this->_templates->format('inputGroupContainer', [ 80 | 'attrs' => $this->_templates->formatAttributes($attrs), 81 | 'append' => $append, 82 | 'prepend' => $prepend, 83 | 'content' => $input, 84 | 'templateVars' => $data['templateVars'], 85 | ]); 86 | } 87 | 88 | return $input; 89 | } 90 | 91 | /** 92 | * Get addon HTML. 93 | * 94 | * @param array $addons Addon content. 95 | * @param array $data Widget data. 96 | * @return string 97 | */ 98 | protected function _addon(array $addons, array $data): string 99 | { 100 | $content = []; 101 | 102 | foreach ($addons as $addon) { 103 | if ($this->_isButton($addon)) { 104 | $content[] = $addon; 105 | } else { 106 | $content[] = $this->_templates->format('inputGroupText', [ 107 | 'content' => $addon, 108 | ]); 109 | } 110 | } 111 | 112 | return implode('', $content); 113 | } 114 | 115 | /** 116 | * Checks if an HTML markup is for a button. 117 | * 118 | * @param string $html Markup to check. 119 | * @return bool TRUE if it's a button. 120 | */ 121 | protected function _isButton(string $html): bool 122 | { 123 | return strpos($html, 'genClassName('input-group', $attachment['options']['size']); 168 | $attrs['class'] = $this->_toClassArray($attrs['class']); 169 | } 170 | 171 | if (isset($attachment['options']['class'])) { 172 | $attrs = $this->injectClasses($attachment['options']['class'], $attrs); 173 | } 174 | 175 | unset( 176 | $attachment['options']['size'], 177 | $attachment['options']['class'], 178 | ); 179 | 180 | $attrs += $attachment['options']; 181 | 182 | return $attrs; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/View/Widget/SelectBoxWidget.php: -------------------------------------------------------------------------------- 1 | ['elk' => 'Elk', 'beaver' => 'Beaver'] 39 | * ``` 40 | * 41 | * If you need to define additional attributes on your option elements 42 | * you can use the complex form for options: 43 | * 44 | * ``` 45 | * 'options' => [ 46 | * ['value' => 'elk', 'text' => 'Elk', 'data-foo' => 'bar'], 47 | * ] 48 | * ``` 49 | * 50 | * This form **requires** that both the `value` and `text` keys be defined. 51 | * If either is not set options will not be generated correctly. 52 | * 53 | * If you need to define option groups you can do those using nested arrays: 54 | * 55 | * ``` 56 | * 'options' => [ 57 | * 'Mammals' => [ 58 | * 'elk' => 'Elk', 59 | * 'beaver' => 'Beaver' 60 | * ] 61 | * ] 62 | * ``` 63 | * 64 | * And finally, if you need to put attributes on your optgroup elements you 65 | * can do that with a more complex nested array form: 66 | * 67 | * ``` 68 | * 'options' => [ 69 | * [ 70 | * 'text' => 'Mammals', 71 | * 'data-id' => 1, 72 | * 'options' => [ 73 | * 'elk' => 'Elk', 74 | * 'beaver' => 'Beaver' 75 | * ] 76 | * ], 77 | * ] 78 | * ``` 79 | * 80 | * You are free to mix each of the forms in the same option set, and 81 | * nest complex types as required. 82 | * 83 | * @param array $data Data to render with. 84 | * @param \Cake\View\Form\ContextInterface $context The current form context. 85 | * @return string A generated select box. 86 | * @throws \RuntimeException when the name attribute is empty. 87 | */ 88 | public function render(array $data, ContextInterface $context): string 89 | { 90 | return $this->_withInputGroup($data, $context); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/View/Widget/TextareaWidget.php: -------------------------------------------------------------------------------- 1 | _withInputGroup($data, $context); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /templates/bake/Template/add.twig: -------------------------------------------------------------------------------- 1 | 12 | extend('/layout/TwitterBootstrap/dashboard'); ?> 13 | 14 | start('tb_actions'); ?> 15 | {{ element('tb_actions') -}} 16 | end(); ?> 17 | assign('tb_sidebar', ''); ?> 18 | 19 | {{ element('form') -}} 20 | -------------------------------------------------------------------------------- /templates/bake/Template/edit.twig: -------------------------------------------------------------------------------- 1 | 12 | extend('/layout/TwitterBootstrap/dashboard'); ?> 13 | 14 | start('tb_actions'); ?> 15 | {{ element('tb_actions') -}} 16 | end(); ?> 17 | assign('tb_sidebar', ''); ?> 18 | 19 | {{ element('form') -}} 20 | -------------------------------------------------------------------------------- /templates/bake/Template/index.twig: -------------------------------------------------------------------------------- 1 | 7 | extend('/layout/TwitterBootstrap/dashboard'); ?> 8 | 9 | start('tb_actions'); ?> 10 | {% set fields = Bake.filterFields(fields, schema, modelObject, indexColumns, ['binary', 'text']) %} 11 |
  • Html->link(__('New {{ singularHumanName }}'), ['action' => 'add'], ['class' => 'nav-link']) ?>
  • 12 | {% set done = [] %} 13 | {% for type, data in associations %} 14 | {% for alias, details in data %} 15 | {% if details.navLink and details.controller is not same as(_view.name) and details.controller not in done %} 16 |
  • Html->link(__('List {{ alias|underscore|humanize }}'), ['controller' => '{{ details.controller }}', 'action' => 'index'], ['class' => 'nav-link']) ?>
  • 17 |
  • Html->link(__('New {{ alias|singularize|underscore|humanize }}'), ['controller' => '{{ details.controller }}', 'action' => 'add'], ['class' => 'nav-link']) ?>
  • 18 | {% set done = done|merge([details.controller]) %} 19 | {% endif %} 20 | {% endfor %} 21 | {% endfor %} 22 | end(); ?> 23 | assign('tb_sidebar', ''); ?> 24 | 25 | 26 | 27 | 28 | {% for field in fields %} 29 | 30 | {% endfor %} 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% for field in fields %} 38 | {% set isKey = false %} 39 | {% if associations.BelongsTo is defined %} 40 | {% for alias, details in associations.BelongsTo %} 41 | {% if field == details.foreignKey %} 42 | {% set isKey = true %} 43 | 44 | {% endif %} 45 | {% endfor %} 46 | {% endif %} 47 | {% if isKey is not same as(true) %} 48 | {% set columnData = Bake.columnData(field, schema) %} 49 | {% if columnData.type not in ['integer', 'float', 'decimal', 'biginteger', 'smallinteger', 'tinyinteger'] %} 50 | 51 | {% elseif columnData.null %} 52 | 53 | {% else %} 54 | 55 | {% endif %} 56 | {% endif %} 57 | {% endfor %} 58 | {% set pk = '$' ~ singularVar ~ '->' ~ primaryKey[0] %} 59 | 64 | 65 | 66 | 67 |
    Paginator->sort('{{ field }}') ?>
    hasValue('{{ details.property }}') ? $this->Html->link(${{ singularVar }}->{{ details.property }}->{{ details.displayField }}, ['controller' => '{{ details.controller }}', 'action' => 'view', ${{ singularVar }}->{{ details.property }}->{{ details.primaryKey[0] }}]) : '' ?>{{ field }}) ?>{{ field }} === null ? '' : $this->Number->format(${{ singularVar }}->{{ field }}) ?>Number->format(${{ singularVar }}->{{ field }}) ?> 60 | Html->link(__('View'), ['action' => 'view', {{ pk|raw }}], ['title' => __('View'), 'class' => 'btn btn-secondary']) ?> 61 | Html->link(__('Edit'), ['action' => 'edit', {{ pk|raw }}], ['title' => __('Edit'), 'class' => 'btn btn-secondary']) ?> 62 | Form->postLink(__('Delete'), ['action' => 'delete', {{ pk|raw }}], ['confirm' => __('Are you sure you want to delete # {0}?', {{ pk|raw }}), 'title' => __('Delete'), 'class' => 'btn btn-danger']) ?> 63 |
    68 |
    69 |
      70 | Paginator->first('«', ['label' => __('First')]) ?> 71 | Paginator->prev('‹', ['label' => __('Previous')]) ?> 72 | Paginator->numbers() ?> 73 | Paginator->next('›', ['label' => __('Next')]) ?> 74 | Paginator->last('»', ['label' => __('Last')]) ?> 75 |
    76 |

    Paginator->counter(__('Page {{ '{{' }}page{{ '}}' }} of {{ '{{' }}pages{{ '}}' }}, showing {{ '{{' }}current{{ '}}' }} record(s) out of {{ '{{' }}count{{ '}}' }} total')) ?>

    77 |
    78 | -------------------------------------------------------------------------------- /templates/bake/Template/login.twig: -------------------------------------------------------------------------------- 1 | 7 | extend('/layout/TwitterBootstrap/signin'); ?> 8 | 9 | Form->create(${{ singularVar }}, ['class' => 'form-signin']) ?> 10 | Html->image('BootstrapUI.baked-with-cakephp.svg', ['class' => 'mb-4', 'width' => '250']) ?> 11 |

    12 | Flash->render(); ?> 13 | Form->control('email', ['label' => ['floating' => true], 'autofocus']) ?> 14 | Form->control('password', ['type' => 'password', 'label' => ['floating' => true]]) ?> 15 | Form->control('remember-me', ['type' => 'checkbox', 'inline' => true]) ?> 16 | Form->submit(__('Sign in'), ['class' => 'w-100 btn btn-lg btn-primary']) ?> 17 |

    ©

    18 | Form->end() ?> 19 | -------------------------------------------------------------------------------- /templates/bake/Template/view.twig: -------------------------------------------------------------------------------- 1 | 7 | extend('/layout/TwitterBootstrap/dashboard'); ?> 8 | {% set associations = {'BelongsTo': [], 'HasOne': [], 'HasMany': [], 'BelongsToMany': []}|merge(associations) %} 9 | {% set fieldsData = Bake.getViewFieldsData(fields, schema, associations) %} 10 | {% set associationFields = fieldsData.associationFields %} 11 | {% set groupedFields = fieldsData.groupedFields %} 12 | {% set pK = '$' ~ singularVar ~ '->' ~ primaryKey[0] %} 13 | 14 | start('tb_actions'); ?> 15 |
  • Html->link(__('Edit {{ singularHumanName }}'), ['action' => 'edit', {{ pK|raw }}], ['class' => 'nav-link']) ?>
  • 16 |
  • Form->postLink(__('Delete {{ singularHumanName }}'), ['action' => 'delete', {{ pK|raw }}], ['confirm' => __('Are you sure you want to delete # {0}?', ${{ singularVar }}->{{ primaryKey[0] }}), 'class' => 'nav-link']) ?>
  • 17 |
  • Html->link(__('List {{ pluralHumanName }}'), ['action' => 'index'], ['class' => 'nav-link']) ?>
  • 18 |
  • Html->link(__('New {{ singularHumanName }}'), ['action' => 'add'], ['class' => 'nav-link']) ?>
  • 19 | {% set done = [] %} 20 | {% for type, data in associations %} 21 | {% for alias, details in data %} 22 | {% if details.controller is not same as(_view.name) and details.controller not in done %} 23 |
  • Html->link(__('List {{ alias|underscore|humanize }}'), ['controller' => '{{ details.controller }}', 'action' => 'index'], ['class' => 'nav-link']) ?>
  • 24 |
  • Html->link(__('New {{ alias|underscore|singularize|humanize }}'), ['controller' => '{{ details.controller }}', 'action' => 'add'], ['class' => 'nav-link']) ?>
  • 25 | {% set done = done|merge(['controller']) %} 26 | {% endif %} 27 | {% endfor %} 28 | {% endfor %} 29 | end(); ?> 30 | assign('tb_sidebar', ''); ?> 31 | 32 |
    33 |

    {{ displayField }}) ?>

    34 |
    35 | 36 | {% if groupedFields['string'] %} 37 | {% for field in groupedFields['string'] %} 38 | {% if associationFields[field] is defined %} 39 | {% set details = associationFields[field] %} 40 | 41 | 42 | 43 | 44 | {% else %} 45 | 46 | 47 | 48 | 49 | {% endif %} 50 | {% endfor %} 51 | {% endif %} 52 | {% if associations.HasOne %} 53 | {% for alias, details in associations.HasOne %} 54 | 55 | 56 | 57 | 58 | {% endfor %} 59 | {% endif %} 60 | {% if groupedFields.number %} 61 | {% for field in groupedFields.number %} 62 | 63 | 64 | {% set columnData = Bake.columnData(field, schema) %} 65 | {% if columnData.null %} 66 | 67 | {% else %} 68 | 69 | {% endif %} 70 | 71 | {% endfor %} 72 | {% endif %} 73 | {% if groupedFields.date %} 74 | {% for field in groupedFields.date %} 75 | 76 | 77 | 78 | 79 | {% endfor %} 80 | {% endif %} 81 | {% if groupedFields.boolean %} 82 | {% for field in groupedFields.boolean %} 83 | 84 | 85 | 86 | 87 | {% endfor %} 88 | {% endif %} 89 |
    hasValue('{{ details.property }}') ? $this->Html->link(${{ singularVar }}->{{ details.property }}->{{ details.displayField }}, ['controller' => '{{ details.controller }}', 'action' => 'view', ${{ singularVar }}->{{ details.property }}->{{ details.primaryKey[0] }}]) : '' ?>
    {{ field }}) ?>
    hasValue('{{ details.property }}') ? $this->Html->link(${{ singularVar }}->{{ details.property }}->{{ details.displayField }}, ['controller' => '{{ details.controller }}', 'action' => 'view', ${{ singularVar }}->{{ details.property }}->{{ details.primaryKey[0] }}]) : '' ?>
    {{ field }} === null ? '' : $this->Number->format(${{ singularVar }}->{{ field }}) ?>Number->format(${{ singularVar }}->{{ field }}) ?>
    {{ field }}) ?>
    {{ field }} ? __('Yes') : __('No'); ?>
    90 |
    91 | {% if groupedFields.text %} 92 | {% for field in groupedFields.text %} 93 |
    94 |

    95 | Text->autoParagraph(h(${{ singularVar }}->{{ field }})); ?> 96 |
    97 | {% endfor %} 98 | {% endif %} 99 | {% set relations = associations.BelongsToMany|merge(associations.HasMany) %} 100 | {% for alias, details in relations %} 101 | {% set otherSingularVar = alias|variable %} 102 | {% set otherPluralHumanName = details.controller|underscore|humanize %} 103 | 131 | {% endfor %} 132 |
    133 | -------------------------------------------------------------------------------- /templates/bake/element/form.twig: -------------------------------------------------------------------------------- 1 |
    2 | Form->create(${{ singularVar }}) ?> 3 |
    4 | 5 | Form->control('{{ field }}', ['options' => ${{ keyFields[field] }}, 'empty' => true]); 12 | {{- "\n" }} 13 | {%- else %} 14 | echo $this->Form->control('{{ field }}', ['options' => ${{ keyFields[field] }}]); 15 | {{- "\n" }} 16 | {%- endif %} 17 | {%- elseif field not in ['created', 'modified', 'updated'] %} 18 | {%- set fieldData = Bake.columnData(field, schema) %} 19 | {%- if fieldData.type in ['date', 'datetime', 'time'] and fieldData.null %} 20 | echo $this->Form->control('{{ field }}', ['empty' => true]); 21 | {{- "\n" }} 22 | {%- else %} 23 | echo $this->Form->control('{{ field }}'); 24 | {{- "\n" }} 25 | {%- endif %} 26 | {%- endif %} 27 | {%- endif %} 28 | {%- endfor %} 29 | 30 | {%- if associations.BelongsToMany is defined %} 31 | {%- for assocName, assocData in associations.BelongsToMany %} 32 | echo $this->Form->control('{{ assocData.property }}._ids', ['options' => ${{ assocData.variable }}]); 33 | {{- "\n" }} 34 | {%- endfor %} 35 | {% endif %} 36 | ?> 37 |
    38 | Form->button(__('Submit')) ?> 39 | Form->end() ?> 40 |
    41 | -------------------------------------------------------------------------------- /templates/bake/element/tb_actions.twig: -------------------------------------------------------------------------------- 1 | {% set fields = Bake.filterFields(fields, schema, modelObject) %} 2 | {%- if 'add' not in action %} 3 |
  • Form->postLink(__('Delete'), ['action' => 'delete', ${{ singularVar }}->{{ primaryKey[0] }}], ['confirm' => __('Are you sure you want to delete # {0}?', ${{ singularVar }}->{{ primaryKey[0] }}), 'class' => 'nav-link']) ?>
  • 4 | {% endif %} 5 |
  • Html->link(__('List {{ pluralHumanName }}'), ['action' => 'index'], ['class' => 'nav-link']) ?>
  • 6 | {% set done = [] %} 7 | {% for type, data in associations %} 8 | {% for alias, details in data %} 9 | {% if details.controller is not same as(_view.name) and details.controller not in done %} 10 |
  • Html->link(__('List {{ alias|underscore|humanize }}'), ['controller' => '{{ details.controller }}', 'action' => 'index'], ['class' => 'nav-link']) ?>
  • 11 |
  • Html->link(__('New {{ alias|singularize|underscore|humanize }}'), ['controller' => '{{ details.controller }}', 'action' => 'add'], ['class' => 'nav-link']) ?>
  • 12 | {% set done = done|merge([details.controller]) %} 13 | {% endif %} 14 | {% endfor %} 15 | {% endfor %} 16 | -------------------------------------------------------------------------------- /templates/element/flash/default.php: -------------------------------------------------------------------------------- 1 | Html->icon($icon, $params['iconOptions']); 17 | } 18 | $message = $icon . "
    $message
    "; 19 | } 20 | 21 | if (in_array('alert-dismissible', $class)) { 22 | $button = << 24 | BUTTON; 25 | $message = $message . $button; 26 | } 27 | if (is_array($class)) { 28 | $class = join(' ', $class); 29 | } 30 | echo $this->Html->div($class, $message, $params['attributes']); 31 | -------------------------------------------------------------------------------- /templates/layout/default.php: -------------------------------------------------------------------------------- 1 | fetch('html')) { 12 | $this->start('html'); 13 | if (Configure::check('App.language')) { 14 | printf('', Configure::read('App.language')); 15 | } else { 16 | echo ''; 17 | } 18 | $this->end(); 19 | } 20 | 21 | /** 22 | * Default `title` block. 23 | */ 24 | if (!$this->fetch('title')) { 25 | $this->start('title'); 26 | echo Configure::read('App.title'); 27 | $this->end(); 28 | } 29 | 30 | /** 31 | * Default `footer` block. 32 | */ 33 | if (!$this->fetch('tb_footer')) { 34 | $this->start('tb_footer'); 35 | if (Configure::check('App.title')) { 36 | printf('©%s %s', date('Y'), Configure::read('App.title')); 37 | } else { 38 | printf('©%s', date('Y')); 39 | } 40 | $this->end(); 41 | } 42 | 43 | /** 44 | * Default `body` block. 45 | */ 46 | $this->prepend( 47 | 'tb_body_attrs', 48 | ' class="' . implode(' ', [h($this->request->getParam('controller')), h($this->request->getParam('action'))]) . '" ' 49 | ); 50 | if (!$this->fetch('tb_body_start')) { 51 | $this->start('tb_body_start'); 52 | echo 'fetch('tb_body_attrs') . '>'; 53 | $this->end(); 54 | } 55 | /** 56 | * Default `flash` block. 57 | */ 58 | if (!$this->fetch('tb_flash')) { 59 | $this->start('tb_flash'); 60 | echo $this->Flash->render(); 61 | $this->end(); 62 | } 63 | if (!$this->fetch('tb_body_end')) { 64 | $this->start('tb_body_end'); 65 | echo ''; 66 | $this->end(); 67 | } 68 | 69 | /** 70 | * Prepend `meta` block with `author` and `favicon`. 71 | */ 72 | if (Configure::check('App.author')) { 73 | $this->prepend( 74 | 'meta', 75 | $this->Html->meta('author', null, ['name' => 'author', 'content' => Configure::read('App.author')]) 76 | ); 77 | } 78 | $this->prepend('meta', $this->Html->meta('favicon.ico', '/favicon.ico', ['type' => 'icon'])); 79 | 80 | /** 81 | * Prepend `css` block with Bootstrap stylesheets 82 | * Change to bootstrap.min to use the compressed version 83 | */ 84 | if (Configure::read('debug')) { 85 | $this->prepend('css', $this->Html->css(['BootstrapUI.bootstrap'])); 86 | } else { 87 | $this->prepend('css', $this->Html->css(['BootstrapUI.bootstrap.min'])); 88 | } 89 | $this->prepend( 90 | 'css', 91 | $this->Html->css(['BootstrapUI./font/bootstrap-icons', 'BootstrapUI./font/bootstrap-icon-sizes']) 92 | ); 93 | 94 | /** 95 | * Prepend `script` block with Popper and Bootstrap scripts 96 | * Change bootstrap.min to use the compressed version 97 | */ 98 | if (Configure::read('debug')) { 99 | $this->prepend('script', $this->Html->script(['BootstrapUI.bootstrap.bundle'])); 100 | } else { 101 | $this->prepend('script', $this->Html->script(['BootstrapUI.bootstrap.bundle.min'])); 102 | } 103 | 104 | ?> 105 | 106 | fetch('html') ?> 107 | 108 | Html->charset() ?> 109 | 110 | <?= h($this->fetch('title')) ?> 111 | fetch('meta') ?> 112 | fetch('css') ?> 113 | 114 | 115 | fetch('tb_body_start'); 117 | echo $this->fetch('tb_flash'); 118 | echo $this->fetch('content'); 119 | echo $this->fetch('tb_footer'); 120 | echo $this->fetch('script'); 121 | echo $this->fetch('tb_body_end'); 122 | ?> 123 | 124 | 125 | -------------------------------------------------------------------------------- /templates/layout/examples/cover.php: -------------------------------------------------------------------------------- 1 | start('html'); 8 | printf('', Configure::read('App.language')); 9 | $this->end(); 10 | 11 | $this->Html->css('BootstrapUI.cover', ['block' => true]); 12 | 13 | $this->prepend( 14 | 'tb_body_attrs', 15 | 'class="d-flex h-100 text-center text-white bg-dark ' . 16 | implode(' ', [h($this->request->getParam('controller')), h($this->request->getParam('action'))]) . 17 | '" ' 18 | ); 19 | 20 | $this->start('tb_body_start'); ?> 21 | fetch('tb_body_attrs') ?>> 22 |
    23 |
    24 |
    25 |

    26 | 29 |
    30 |
    31 |
    32 | fetch('content') ?> 33 |
    34 | end(); ?> 35 | 36 | start('tb_body_end'); ?> 37 |
    38 | 39 | end(); ?> 40 | 41 | start('tb_footer'); 43 | printf( 44 | '

    ©%s %s

    ', 45 | date('Y'), 46 | Configure::read('App.title') 47 | ); 48 | $this->end(); 49 | -------------------------------------------------------------------------------- /templates/layout/examples/dashboard.php: -------------------------------------------------------------------------------- 1 | Html->css('BootstrapUI.dashboard', ['block' => true]); 8 | $this->prepend( 9 | 'tb_body_attrs', 10 | ' class="' . 11 | implode(' ', [h($this->request->getParam('controller')), h($this->request->getParam('action'))]) . 12 | '" ' 13 | ); 14 | $this->start('tb_body_start'); 15 | ?> 16 | fetch('tb_body_attrs') ?>> 17 | 37 | 38 |
    39 |
    40 | 45 | 46 |
    47 |
    49 |

    request->getParam('controller')) ?>

    50 |
    51 | fetch('tb_flash')) { 56 | $this->start('tb_flash'); 57 | if (isset($this->Flash)) { 58 | echo $this->Flash->render(); 59 | } 60 | $this->end(); 61 | } 62 | $this->end(); 63 | 64 | $this->start('tb_body_end'); 65 | ?> 66 |
    67 |
    68 |
    69 | 70 | end(); 72 | 73 | echo $this->fetch('content'); 74 | -------------------------------------------------------------------------------- /templates/layout/examples/signin.php: -------------------------------------------------------------------------------- 1 | Html->css('BootstrapUI.signin', ['block' => true]); 6 | $this->prepend( 7 | 'tb_body_attrs', 8 | ' class="text-center ' . 9 | implode(' ', [h($this->request->getParam('controller')), h($this->request->getParam('action'))]) . 10 | '" ' 11 | ); 12 | $this->start('tb_body_start'); 13 | /** 14 | * Default `flash` block. 15 | */ 16 | if (!$this->fetch('tb_flash')) { 17 | $this->start('tb_flash'); 18 | echo $this->Flash->render(); 19 | $this->end(); 20 | } 21 | ?> 22 | fetch('tb_body_attrs') ?>> 23 | end(); 25 | 26 | $this->start('tb_body_end'); 27 | echo ''; 28 | $this->end(); 29 | 30 | $this->start('tb_footer'); 31 | echo ' '; 32 | $this->end(); 33 | 34 | echo $this->fetch('content'); 35 | -------------------------------------------------------------------------------- /webroot/css/cover.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Globals 3 | */ 4 | 5 | 6 | /* Custom default button */ 7 | .btn-secondary, 8 | .btn-secondary:hover, 9 | .btn-secondary:focus { 10 | color: #333; 11 | text-shadow: none; /* Prevent inheritance from `body` */ 12 | } 13 | 14 | 15 | /* 16 | * Base structure 17 | */ 18 | 19 | body { 20 | text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5); 21 | box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5); 22 | } 23 | 24 | .cover-container { 25 | max-width: 42em; 26 | } 27 | 28 | 29 | /* 30 | * Header 31 | */ 32 | 33 | .nav-masthead .nav-link { 34 | padding: .25rem 0; 35 | font-weight: 700; 36 | color: rgba(255, 255, 255, .5); 37 | background-color: transparent; 38 | border-bottom: .25rem solid transparent; 39 | } 40 | 41 | .nav-masthead .nav-link:hover, 42 | .nav-masthead .nav-link:focus { 43 | border-bottom-color: rgba(255, 255, 255, .25); 44 | } 45 | 46 | .nav-masthead .nav-link + .nav-link { 47 | margin-left: 1rem; 48 | } 49 | 50 | .nav-masthead .active { 51 | color: #fff; 52 | border-bottom-color: #fff; 53 | } 54 | -------------------------------------------------------------------------------- /webroot/css/dashboard.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: .875rem; 3 | } 4 | 5 | .feather { 6 | width: 16px; 7 | height: 16px; 8 | vertical-align: text-bottom; 9 | } 10 | 11 | /* 12 | * Sidebar 13 | */ 14 | 15 | .sidebar { 16 | position: fixed; 17 | top: 0; 18 | /* rtl:raw: 19 | right: 0; 20 | */ 21 | bottom: 0; 22 | /* rtl:remove */ 23 | left: 0; 24 | z-index: 100; /* Behind the navbar */ 25 | padding: 48px 0 0; /* Height of navbar */ 26 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 27 | } 28 | 29 | @media (max-width: 767.98px) { 30 | .sidebar { 31 | top: 5rem; 32 | } 33 | } 34 | 35 | .sidebar-sticky { 36 | position: relative; 37 | top: 0; 38 | height: calc(100vh - 48px); 39 | padding-top: .5rem; 40 | overflow-x: hidden; 41 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 42 | } 43 | 44 | .sidebar .nav-link { 45 | font-weight: 500; 46 | color: #333; 47 | } 48 | 49 | .sidebar .nav-link .feather { 50 | margin-right: 4px; 51 | color: #727272; 52 | } 53 | 54 | .sidebar .nav-link.active { 55 | color: #007bff; 56 | } 57 | 58 | .sidebar .nav-link:hover .feather, 59 | .sidebar .nav-link.active .feather { 60 | color: inherit; 61 | } 62 | 63 | .sidebar-heading { 64 | font-size: .75rem; 65 | text-transform: uppercase; 66 | } 67 | 68 | /* 69 | * Navbar 70 | */ 71 | 72 | .navbar-brand { 73 | padding-top: .75rem; 74 | padding-bottom: .75rem; 75 | font-size: 1rem; 76 | background-color: rgba(0, 0, 0, .25); 77 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); 78 | } 79 | 80 | .navbar .navbar-toggler { 81 | top: .25rem; 82 | right: 1rem; 83 | } 84 | 85 | .navbar .form-control { 86 | padding: .75rem 1rem; 87 | border-width: 0; 88 | border-radius: 0; 89 | } 90 | 91 | .form-control-dark { 92 | color: #fff; 93 | background-color: rgba(255, 255, 255, .1); 94 | border-color: rgba(255, 255, 255, .1); 95 | } 96 | 97 | .form-control-dark:focus { 98 | border-color: transparent; 99 | box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); 100 | } 101 | -------------------------------------------------------------------------------- /webroot/css/signin.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | padding-top: 40px; 11 | padding-bottom: 40px; 12 | background-color: #f5f5f5; 13 | } 14 | 15 | .form-signin { 16 | width: 100%; 17 | max-width: 330px; 18 | padding: 15px; 19 | margin: auto; 20 | } 21 | 22 | .form-signin .checkbox { 23 | font-weight: 400; 24 | } 25 | 26 | .form-signin .form-control { 27 | position: relative; 28 | } 29 | .form-signin .form-group.email { 30 | margin-bottom: -1px !important; 31 | } 32 | .form-signin .form-floating:focus-within { 33 | z-index: 2; 34 | } 35 | 36 | .form-signin input[type="email"] { 37 | border-bottom-right-radius: 0; 38 | border-bottom-left-radius: 0; 39 | } 40 | 41 | .form-signin input[type="password"] { 42 | margin-bottom: 10px; 43 | border-top-left-radius: 0; 44 | border-top-right-radius: 0; 45 | } 46 | -------------------------------------------------------------------------------- /webroot/font/bootstrap-icon-sizes.css: -------------------------------------------------------------------------------- 1 | .bi-2xs { 2 | font-size: .625rem; 3 | line-height: .1em; 4 | vertical-align: .225em; 5 | } 6 | .bi-xs { 7 | font-size: .75rem; 8 | line-height: .08333em; 9 | vertical-align: .125em; 10 | } 11 | .bi-sm { 12 | font-size: .875rem; 13 | line-height: .07143em; 14 | vertical-align: .05357em; 15 | } 16 | .bi-lg { 17 | font-size: 1.25rem; 18 | line-height: .05em; 19 | vertical-align: -.075em; 20 | } 21 | .bi-xl { 22 | font-size: 1.5rem; 23 | line-height: .04167em; 24 | vertical-align: -.125em; 25 | } 26 | .bi-2xl { 27 | font-size: 2rem; 28 | line-height: 1; 29 | vertical-align: -.12em; 30 | } 31 | 32 | .bi-1x { 33 | font-size: 1rem; 34 | } 35 | .bi-2x { 36 | font-size: 2rem; 37 | } 38 | .bi-3x { 39 | font-size: 3rem; 40 | } 41 | .bi-4x { 42 | font-size: 4rem; 43 | } 44 | .bi-5x { 45 | font-size: 5rem; 46 | } 47 | .bi-6x { 48 | font-size: 6rem; 49 | } 50 | .bi-7x { 51 | font-size: 7rem; 52 | } 53 | .bi-8x { 54 | font-size: 8rem; 55 | } 56 | .bi-9x { 57 | font-size: 9rem; 58 | } 59 | .bi-10x { 60 | font-size: 10rem; 61 | } 62 | -------------------------------------------------------------------------------- /webroot/img/baked-with-cakephp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 10 | 11 | 12 | 14 | 16 | 18 | 22 | 27 | 29 | 33 | 36 | 37 | 40 | 41 | 42 | 44 | 45 | 46 | 47 | 48 | 49 | 53 | 55 | 57 | 58 | 61 | 62 | 63 | 64 | 65 | --------------------------------------------------------------------------------