Passed
Push — master ( 1b2ad2...d912e9 )
by Alexander
02:41
created

Accordion::renderItems()   B

Complexity

Conditions 8
Paths 18

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 8

Importance

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