yiisoft /
yii-bootstrap5
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace Yiisoft\Yii\Bootstrap5; |
||
| 6 | |||
| 7 | use JsonException; |
||
| 8 | use RuntimeException; |
||
| 9 | use Stringable; |
||
| 10 | use Yiisoft\Arrays\ArrayHelper; |
||
| 11 | use Yiisoft\Html\Html; |
||
| 12 | |||
| 13 | use function array_key_exists; |
||
| 14 | use function implode; |
||
| 15 | use function is_array; |
||
| 16 | use function is_numeric; |
||
| 17 | use function is_string; |
||
| 18 | |||
| 19 | /** |
||
| 20 | * Accordion renders an accordion bootstrap JavaScript component. |
||
| 21 | * |
||
| 22 | * For example: |
||
| 23 | * |
||
| 24 | * ```php |
||
| 25 | * echo Accordion::widget() |
||
| 26 | * ->items([ |
||
| 27 | * [ |
||
| 28 | * 'label' => 'Accordion Item #1', |
||
| 29 | * 'content' => [ |
||
| 30 | * 'This is the first items accordion body. It is shown by default, until the collapse plugin ' . |
||
| 31 | * 'the appropriate classes that we use to style each element. These classes control the ' . |
||
| 32 | * 'overall appearance, as well as the showing and hiding via CSS transitions. You can ' . |
||
| 33 | * 'modify any of this with custom CSS or overriding our default variables. Its also worth ' . |
||
| 34 | * 'noting that just about any HTML can go within the .accordion-body, though the transition ' . |
||
| 35 | * 'does limit overflow.', |
||
| 36 | * ], |
||
| 37 | * ], |
||
| 38 | * [ |
||
| 39 | * 'label' => 'Accordion Item #2', |
||
| 40 | * 'content' => '<strong>This is the second items accordion body.</strong> It is hidden by default, ' . |
||
| 41 | * 'until the collapse plugin adds the appropriate classes that we use to style each element. ' . |
||
| 42 | * 'These classes control the overall appearance, as well as the showing and hiding via CSS ' . |
||
| 43 | * 'transitions. You can modify any of this with custom CSS or overriding our default ' . |
||
| 44 | * 'variables. Its also worth noting that just about any HTML can go within the ' . |
||
| 45 | * '<code>.accordion-body</code>, though the transition does limit overflow.', |
||
| 46 | * 'contentOptions' => [ |
||
| 47 | * 'class' => 'testContentOptions', |
||
| 48 | * ], |
||
| 49 | * 'options' => [ |
||
| 50 | * 'class' => 'testClass', |
||
| 51 | * 'id' => 'testId', |
||
| 52 | * ], |
||
| 53 | * ], |
||
| 54 | * [ |
||
| 55 | * 'label' => '<b>Accordion Item #3</b>', |
||
| 56 | * 'content' => [ |
||
| 57 | * '<b>test content1</b>', |
||
| 58 | * '<strong>This is the third items accordion body.</strong> It is hidden by default, until the ' . |
||
| 59 | * 'collapse plugin adds the appropriate classes that we use to style each element. These ' . |
||
| 60 | * 'classes control the overall appearance, as well as the showing and hiding via CSS ' . |
||
| 61 | * 'transitions. You can modify any of this with custom CSS or overriding our default ' . |
||
| 62 | * 'variables. Its also worth noting that just about any HTML can go within the ' . |
||
| 63 | * '<code>.accordion-body</code>, though the transition does limit overflow.', |
||
| 64 | * ], |
||
| 65 | * 'contentOptions' => [ |
||
| 66 | * 'class' => 'testContentOptions2', |
||
| 67 | * ], |
||
| 68 | * 'options' => [ |
||
| 69 | * 'class' => 'testClass2', |
||
| 70 | * 'id' => 'testId2', |
||
| 71 | * ], |
||
| 72 | * 'encode' => false, |
||
| 73 | * ], |
||
| 74 | * ]); |
||
| 75 | * ``` |
||
| 76 | * |
||
| 77 | * @link https://getbootstrap.com/docs/5.0/components/accordion/ |
||
| 78 | */ |
||
| 79 | final class Accordion extends Widget |
||
| 80 | { |
||
| 81 | private array $items = []; |
||
| 82 | private array $expands = []; |
||
| 83 | private ?bool $defaultExpand = null; |
||
| 84 | private bool $encodeLabels = true; |
||
| 85 | private bool $encodeTags = false; |
||
| 86 | private bool $autoCloseItems = true; |
||
| 87 | private array $itemOptions = []; |
||
| 88 | private array $headerOptions = []; |
||
| 89 | private array $toggleOptions = []; |
||
| 90 | private array $contentOptions = []; |
||
| 91 | private array $bodyOptions = []; |
||
| 92 | private array $options = []; |
||
| 93 | private bool $flush = false; |
||
| 94 | |||
| 95 | 17 | public function getId(?string $suffix = '-accordion'): ?string |
|
| 96 | { |
||
| 97 | 17 | return $this->options['id'] ?? parent::getId($suffix); |
|
| 98 | } |
||
| 99 | |||
| 100 | /** |
||
| 101 | * @throws JsonException |
||
| 102 | * @return string |
||
| 103 | */ |
||
| 104 | 17 | public function render(): string |
|
| 105 | { |
||
| 106 | 17 | $options = $this->options; |
|
| 107 | 17 | $options['id'] = $this->getId(); |
|
| 108 | 17 | Html::addCssClass($options, ['widget' => 'accordion']); |
|
| 109 | |||
| 110 | 17 | if ($this->flush) { |
|
| 111 | 1 | Html::addCssClass($options, ['flush' => 'accordion-flush']); |
|
| 112 | } |
||
| 113 | |||
| 114 | 17 | if ($this->theme) { |
|
| 115 | $options['data-bs-theme'] = $this->theme; |
||
| 116 | } |
||
| 117 | |||
| 118 | 17 | return Html::div($this->renderItems(), $options) |
|
| 119 | 17 | ->encode($this->encodeTags) |
|
| 120 | 17 | ->render(); |
|
| 121 | } |
||
| 122 | |||
| 123 | /** |
||
| 124 | * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect. |
||
| 125 | * |
||
| 126 | * Set this to `false` to allow keeping multiple items open at once. |
||
| 127 | */ |
||
| 128 | 1 | public function allowMultipleOpenedItems(): self |
|
| 129 | { |
||
| 130 | 1 | $new = clone $this; |
|
| 131 | 1 | $new->autoCloseItems = false; |
|
| 132 | |||
| 133 | 1 | return $new; |
|
| 134 | } |
||
| 135 | |||
| 136 | /** |
||
| 137 | * When tags Labels HTML should not be encoded. |
||
| 138 | */ |
||
| 139 | 1 | public function withoutEncodeLabels(): self |
|
| 140 | { |
||
| 141 | 1 | $new = clone $this; |
|
| 142 | 1 | $new->encodeLabels = false; |
|
| 143 | |||
| 144 | 1 | return $new; |
|
| 145 | } |
||
| 146 | |||
| 147 | /** |
||
| 148 | * List of groups in the collapse widget. Each array element represents a single group with the following structure: |
||
| 149 | * |
||
| 150 | * - label: string, required, the group header label. |
||
| 151 | * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global |
||
| 152 | * `$this->encodeLabels` param. |
||
| 153 | * - content: array|string|object, required, the content (HTML) of the group |
||
| 154 | * - options: array, optional, the HTML attributes of the group |
||
| 155 | * - contentOptions: optional, the HTML attributes of the group's content |
||
| 156 | * |
||
| 157 | * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers |
||
| 158 | * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained |
||
| 159 | * above. |
||
| 160 | * |
||
| 161 | * For example: |
||
| 162 | * |
||
| 163 | * ```php |
||
| 164 | * echo Accordion::widget() |
||
| 165 | * ->items( |
||
| 166 | * [ |
||
| 167 | * [ |
||
| 168 | * 'Introduction' => 'This is the first collapsible menu', |
||
| 169 | * 'Second panel' => [ |
||
| 170 | * 'content' => 'This is the second collapsible menu', |
||
| 171 | * ], |
||
| 172 | * ], |
||
| 173 | * [ |
||
| 174 | * 'label' => 'Third panel', |
||
| 175 | * 'content' => 'This is the third collapsible menu', |
||
| 176 | * ], |
||
| 177 | * ], |
||
| 178 | * ); |
||
| 179 | * ``` |
||
| 180 | */ |
||
| 181 | 17 | public function items(array $value): self |
|
| 182 | { |
||
| 183 | 17 | $new = clone $this; |
|
| 184 | 17 | $new->items = $value; |
|
| 185 | 17 | $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $this->defaultExpand, $new->items); |
|
| 186 | |||
| 187 | 17 | return $new; |
|
| 188 | } |
||
| 189 | |||
| 190 | /** |
||
| 191 | * Set expand property for items without it |
||
| 192 | */ |
||
| 193 | 5 | public function defaultExpand(?bool $default): self |
|
| 194 | { |
||
| 195 | 5 | if ($default === $this->defaultExpand) { |
|
| 196 | return $this; |
||
| 197 | } |
||
| 198 | |||
| 199 | 5 | $new = clone $this; |
|
| 200 | 5 | $new->defaultExpand = $default; |
|
| 201 | 5 | $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $new->defaultExpand, $new->items); |
|
| 202 | |||
| 203 | 5 | return $new; |
|
| 204 | } |
||
| 205 | |||
| 206 | 1 | public function withItemOptions(array $options): self |
|
| 207 | { |
||
| 208 | 1 | $new = clone $this; |
|
| 209 | 1 | $new->itemOptions = $options; |
|
| 210 | |||
| 211 | 1 | return $new; |
|
| 212 | } |
||
| 213 | |||
| 214 | /** |
||
| 215 | * Options for each header if not present in item |
||
| 216 | */ |
||
| 217 | 2 | public function headerOptions(array $options): self |
|
| 218 | { |
||
| 219 | 2 | $new = clone $this; |
|
| 220 | 2 | $new->headerOptions = $options; |
|
| 221 | |||
| 222 | 2 | return $new; |
|
| 223 | } |
||
| 224 | |||
| 225 | /** |
||
| 226 | * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification. |
||
| 227 | * |
||
| 228 | * For example: |
||
| 229 | * |
||
| 230 | * ```php |
||
| 231 | * [ |
||
| 232 | * 'tag' => 'div', |
||
| 233 | * 'class' => 'custom-toggle', |
||
| 234 | * ] |
||
| 235 | * ``` |
||
| 236 | */ |
||
| 237 | 1 | public function toggleOptions(array $options): self |
|
| 238 | { |
||
| 239 | 1 | $new = clone $this; |
|
| 240 | 1 | $new->toggleOptions = $options; |
|
| 241 | |||
| 242 | 1 | return $new; |
|
| 243 | } |
||
| 244 | |||
| 245 | /** |
||
| 246 | * Content options for items if not present in current |
||
| 247 | */ |
||
| 248 | 2 | public function contentOptions(array $options): self |
|
| 249 | { |
||
| 250 | 2 | $new = clone $this; |
|
| 251 | 2 | $new->contentOptions = $options; |
|
| 252 | |||
| 253 | 2 | return $new; |
|
| 254 | } |
||
| 255 | |||
| 256 | /** |
||
| 257 | * The HTML attributes for the widget container tag. The following special options are recognized. |
||
| 258 | * |
||
| 259 | * {@see Html::renderTagAttributes()} for details on how attributes are being rendered. |
||
| 260 | */ |
||
| 261 | 1 | public function options(array $value): self |
|
| 262 | { |
||
| 263 | 1 | $new = clone $this; |
|
| 264 | 1 | $new->options = $value; |
|
| 265 | |||
| 266 | 1 | return $new; |
|
| 267 | } |
||
| 268 | |||
| 269 | 2 | public function bodyOptions(array $options): self |
|
| 270 | { |
||
| 271 | 2 | $new = clone $this; |
|
| 272 | 2 | $new->bodyOptions = $options; |
|
| 273 | |||
| 274 | 2 | return $new; |
|
| 275 | } |
||
| 276 | |||
| 277 | /** |
||
| 278 | * Remove the default background-color, some borders, and some rounded corners to render accordions |
||
| 279 | * edge-to-edge with their parent container. |
||
| 280 | * |
||
| 281 | * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush |
||
| 282 | */ |
||
| 283 | 1 | public function flush(): self |
|
| 284 | { |
||
| 285 | 1 | $new = clone $this; |
|
| 286 | 1 | $new->flush = true; |
|
| 287 | |||
| 288 | 1 | return $new; |
|
| 289 | } |
||
| 290 | |||
| 291 | /** |
||
| 292 | * Renders collapsible items as specified on {@see items}. |
||
| 293 | * |
||
| 294 | * @throws JsonException|RuntimeException |
||
| 295 | * |
||
| 296 | * @return string the rendering result |
||
| 297 | */ |
||
| 298 | 17 | private function renderItems(): string |
|
| 299 | { |
||
| 300 | 17 | $items = []; |
|
| 301 | 17 | $index = 0; |
|
| 302 | 17 | $expanded = in_array(true, $this->expands, true); |
|
| 303 | 17 | $allClose = !$expanded && count($this->items) === count(array_filter($this->expands, static fn ($expand) => $expand === false)); |
|
| 304 | |||
| 305 | 17 | foreach ($this->items as $item) { |
|
| 306 | 17 | if (!is_array($item)) { |
|
| 307 | 1 | $item = ['content' => $item]; |
|
| 308 | } |
||
| 309 | |||
| 310 | 17 | if ($allClose === false && $expanded === false && $index === 0) { |
|
| 311 | 11 | $item['expand'] = true; |
|
| 312 | } |
||
| 313 | |||
| 314 | 17 | if (!array_key_exists('label', $item)) { |
|
| 315 | 3 | throw new RuntimeException('The "label" option is required.'); |
|
| 316 | } |
||
| 317 | |||
| 318 | 14 | $options = ArrayHelper::getValue($item, 'options', $this->itemOptions); |
|
| 319 | 14 | $tag = ArrayHelper::remove($options, 'tag', 'div'); |
|
| 320 | 14 | $item = $this->renderItem($item); |
|
| 321 | |||
| 322 | 12 | Html::addCssClass($options, ['panel' => 'accordion-item']); |
|
| 323 | |||
| 324 | 12 | $items[] = Html::tag($tag, $item, $options) |
|
| 325 | 12 | ->encode(false) |
|
| 326 | 12 | ->render(); |
|
| 327 | |||
| 328 | 12 | $index++; |
|
| 329 | } |
||
| 330 | |||
| 331 | 12 | return implode('', $items); |
|
| 332 | } |
||
| 333 | |||
| 334 | /** |
||
| 335 | * Renders a single collapsible item group. |
||
| 336 | * |
||
| 337 | * @param array $item a single item from {@see items} |
||
| 338 | * @param int $index the item index as each item group content must have an id |
||
| 339 | * |
||
| 340 | * @throws JsonException|RuntimeException |
||
| 341 | * |
||
| 342 | * @return string the rendering result |
||
| 343 | */ |
||
| 344 | 14 | private function renderItem(array $item): string |
|
| 345 | { |
||
| 346 | 14 | if (!array_key_exists('content', $item)) { |
|
| 347 | 1 | throw new RuntimeException('The "content" option is required.'); |
|
| 348 | } |
||
| 349 | |||
| 350 | 13 | $collapse = $this->renderCollapse($item); |
|
| 351 | 12 | $header = $this->renderHeader($collapse, ArrayHelper::getValue($item, 'headerOptions')); |
|
| 352 | |||
| 353 | 12 | return $header . $collapse->render(); |
|
| 354 | } |
||
| 355 | |||
| 356 | /** |
||
| 357 | * Render collapse header |
||
| 358 | */ |
||
| 359 | 12 | private function renderHeader(Collapse $collapse, ?array $headerOptions): string |
|
| 360 | { |
||
| 361 | 12 | $options = $headerOptions ?? $this->headerOptions; |
|
| 362 | 12 | $tag = ArrayHelper::remove($options, 'tag', 'h2'); |
|
| 363 | |||
| 364 | 12 | Html::addCssClass($options, ['widget' => 'accordion-header']); |
|
| 365 | |||
| 366 | 12 | return Html::tag($tag, $collapse->renderToggle(), $options) |
|
| 367 | 12 | ->encode(false) |
|
| 368 | 12 | ->render(); |
|
| 369 | } |
||
| 370 | |||
| 371 | /** |
||
| 372 | * Render collapse item |
||
| 373 | */ |
||
| 374 | 13 | private function renderCollapse(array $item): Collapse |
|
| 375 | { |
||
| 376 | 13 | $expand = $item['expand'] ?? false; |
|
| 377 | 13 | $options = $item['contentOptions'] ?? $this->contentOptions; |
|
| 378 | 13 | $toggleOptions = $item['toggleOptions'] ?? $this->toggleOptions; |
|
| 379 | 13 | $bodyOptions = $item['bodyOptions'] ?? $this->bodyOptions; |
|
| 380 | |||
| 381 | 13 | $toggleOptions['encode'] ??= $this->encodeLabels; |
|
| 382 | 13 | $bodyOptions['encode'] ??= $this->encodeTags; |
|
| 383 | |||
| 384 | 13 | Html::addCssClass($options, ['accordion-collapse']); |
|
| 385 | 13 | Html::addCssClass($toggleOptions, ['accordion-button']); |
|
| 386 | 13 | Html::addCssClass($bodyOptions, ['widget' => 'accordion-body']); |
|
| 387 | |||
| 388 | 13 | if (!$expand) { |
|
| 389 | 12 | Html::addCssClass($toggleOptions, ['collapsed']); |
|
| 390 | } |
||
| 391 | |||
| 392 | 13 | if ($this->autoCloseItems) { |
|
| 393 | 13 | $options['data-bs-parent'] = '#' . $this->getId(); |
|
| 394 | } |
||
| 395 | |||
| 396 | 13 | return Collapse::widget() |
|
| 397 | 13 | ->withToggleLabel($item['label']) |
|
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 398 | 13 | ->withToggleOptions($toggleOptions) |
|
| 399 | 13 | ->withOptions($options) |
|
| 400 | 13 | ->withContent($this->renderBody($item)) |
|
| 401 | 13 | ->withBodyOptions($bodyOptions) |
|
| 402 | 13 | ->withCollapsed($expand) |
|
| 403 | 13 | ->withToggle(false); |
|
| 404 | } |
||
| 405 | |||
| 406 | /** |
||
| 407 | * Render collapse body |
||
| 408 | */ |
||
| 409 | 13 | private function renderBody(array $item): string |
|
| 410 | { |
||
| 411 | 13 | $items = ''; |
|
| 412 | |||
| 413 | 13 | if ($this->isStringableObject($item['content'])) { |
|
| 414 | 1 | $content = [$item['content']]; |
|
| 415 | } else { |
||
| 416 | 13 | $content = (array) $item['content']; |
|
| 417 | } |
||
| 418 | |||
| 419 | 13 | foreach ($content as $value) { |
|
| 420 | 13 | if (!is_string($value) && !is_numeric($value) && !$this->isStringableObject($value)) { |
|
| 421 | 1 | throw new RuntimeException('The "content" option should be a string, array or object.'); |
|
| 422 | } |
||
| 423 | |||
| 424 | 12 | $items .= $value; |
|
| 425 | } |
||
| 426 | |||
| 427 | 12 | return $items; |
|
| 428 | } |
||
| 429 | |||
| 430 | 13 | private function isStringableObject(mixed $value): bool |
|
| 431 | { |
||
| 432 | 13 | return $value instanceof Stringable; |
|
| 433 | } |
||
| 434 | } |
||
| 435 |