Passed
Push — master ( dfc180...c36365 )
by Sergei
16:19 queued 03:31
created

Accordion::renderCollapse()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 15
c 0
b 0
f 0
nc 8
nop 2
dl 0
loc 25
ccs 16
cts 16
cp 1
crap 4
rs 9.7666
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
12
use function array_key_exists;
13
use function array_merge;
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 $headerOptions = [];
88
    private array $itemToggleOptions = [];
89
    private array $contentOptions = [];
90
    private array $options = [];
91
    private bool $flush = false;
92
93 13
    public function getId(?string $suffix = '-accordion'): ?string
94
    {
95 13
        return $this->options['id'] ?? parent::getId($suffix);
96
    }
97
98 9
    private function getCollapseId(array $item, int $index): string
99
    {
100 9
        return ArrayHelper::getValueByPath($item, ['contentOptions', 'id'], $this->getId() . '-collapse' . $index);
101
    }
102
103 9
    private function getHeaderId(array $item, int $index): string
104
    {
105 9
        return ArrayHelper::getValueByPath($item, ['headerOptions', 'id'], $this->getCollapseId($item, $index) . '-heading');
106
    }
107
108 13
    public function beforeRun(): bool
109
    {
110 13
        Html::addCssClass($this->options, ['widget' => 'accordion']);
111
112 13
        if ($this->flush) {
113 1
            Html::addCssClass($this->options, ['flush' => 'accordion-flush']);
114
        }
115
116 13
        if (!isset($this->options['id'])) {
117 13
            $this->options['id'] = $this->getId();
118
        }
119
120 13
        return parent::beforeRun();
121
    }
122
123 13
    protected function run(): string
124
    {
125 13
        return Html::div($this->renderItems(), $this->options)
126 13
            ->encode($this->encodeTags)
127 13
            ->render();
128
    }
129
130
    /**
131
     * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect.
132
     *
133
     * Set this to `false` to allow keeping multiple items open at once.
134
     */
135 1
    public function allowMultipleOpenedItems(): self
136
    {
137 1
        $new = clone $this;
138 1
        $new->autoCloseItems = false;
139
140 1
        return $new;
141
    }
142
143
    /**
144
     * When tags Labels HTML should not be encoded.
145
     */
146 1
    public function withoutEncodeLabels(): self
147
    {
148 1
        $new = clone $this;
149 1
        $new->encodeLabels = false;
150
151 1
        return $new;
152
    }
153
154
    /**
155
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
156
     *
157
     * - label: string, required, the group header label.
158
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
159
     *   `$this->encodeLabels` param.
160
     * - content: array|string|object, required, the content (HTML) of the group
161
     * - options: array, optional, the HTML attributes of the group
162
     * - contentOptions: optional, the HTML attributes of the group's content
163
     *
164
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
165
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
166
     * above.
167
     *
168
     * For example:
169
     *
170
     * ```php
171
     * echo Accordion::widget()
172
     *     ->items(
173
     *         [
174
     *             [
175
     *                 'Introduction' => 'This is the first collapsible menu',
176
     *                 'Second panel' => [
177
     *                     'content' => 'This is the second collapsible menu',
178
     *                 ],
179
     *             ],
180
     *             [
181
     *                 'label' => 'Third panel',
182
     *                 'content' => 'This is the third collapsible menu',
183
     *             ],
184
     *         ],
185
     *     );
186
     * ```
187
     *
188
     * @param array $value
189
     */
190 13
    public function items(array $value): self
191
    {
192 13
        $new = clone $this;
193 13
        $new->items = $value;
194 13
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $this->defaultExpand, $new->items);
195
196 13
        return $new;
197
    }
198
199
    /**
200
     * Set expand property for items without it
201
     */
202 1
    public function defaultExpand(?bool $default): self
203
    {
204 1
        if ($default === $this->defaultExpand) {
205
            return $this;
206
        }
207
208 1
        $new = clone $this;
209 1
        $new->defaultExpand = $default;
210 1
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $new->defaultExpand, $new->items);
211
212 1
        return $new;
213
    }
214
215
    /**
216
     * Options for each header if not present in item
217
     */
218
    public function headerOptions(array $options): self
219
    {
220
        $new = clone $this;
221
        $new->headerOptions = $options;
222
223
        return $new;
224
    }
225
226
    /**
227
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
228
     *
229
     * For example:
230
     *
231
     * ```php
232
     * [
233
     *     'tag' => 'div',
234
     *     'class' => 'custom-toggle',
235
     * ]
236
     * ```
237
     *
238
     * @param array $value
239
     */
240 1
    public function itemToggleOptions(array $value): self
241
    {
242 1
        $new = clone $this;
243 1
        $new->itemToggleOptions = $value;
244
245 1
        return $new;
246
    }
247
248
    /**
249
     * Content options for items if not present in current
250
     */
251
    public function contentOptions(array $options): self
252
    {
253
        $new = clone $this;
254
        $new->contentOptions = $options;
255
256
        return $new;
257
    }
258
259
    /**
260
     * The HTML attributes for the widget container tag. The following special options are recognized.
261
     *
262
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
263
     *
264
     * @param array $value
265
     */
266 1
    public function options(array $value): self
267
    {
268 1
        $new = clone $this;
269 1
        $new->options = $value;
270
271 1
        return $new;
272
    }
273
274
    /**
275
     * Remove the default background-color, some borders, and some rounded corners to render accordions
276
     * edge-to-edge with their parent container.
277
     *
278
     * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush
279
     */
280 1
    public function flush(): self
281
    {
282 1
        $new = clone $this;
283 1
        $new->flush = true;
284
285 1
        return $new;
286
    }
287
288
    /**
289
     * Renders collapsible items as specified on {@see items}.
290
     *
291
     * @throws JsonException|RuntimeException
292
     *
293
     * @return string the rendering result
294
     */
295 13
    private function renderItems(): string
296
    {
297 13
        $items = [];
298 13
        $index = 0;
299 13
        $expanded = in_array(true, $this->expands, true);
300 13
        $allClose = !$expanded && count($this->items) === count(array_filter($this->expands, fn ($expand) => $expand === false));
301
302 13
        foreach ($this->items as $item) {
303 13
            if (!is_array($item)) {
304 1
                $item = ['content' => $item];
305
            }
306
307 13
            if ($allClose === false && $expanded === false && $index === 0) {
308 11
                $item['expand'] = true;
309
            }
310
311 13
            if (!array_key_exists('label', $item)) {
312 3
                throw new RuntimeException('The "label" option is required.');
313
            }
314
315 10
            $options = ArrayHelper::getValue($item, 'options', []);
316 10
            $item = $this->renderItem($item, $index++);
317
318 8
            Html::addCssClass($options, ['panel' => 'accordion-item']);
319
320 8
            $items[] = Html::div($item, $options)
321 8
                ->encode(false)
322 8
                ->render();
323
        }
324
325 8
        return implode('', $items);
326
    }
327
328
    /**
329
     * Renders a single collapsible item group.
330
     *
331
     * @param array $item a single item from {@see items}
332
     * @param int $index the item index as each item group content must have an id
333
     *
334
     * @throws JsonException|RuntimeException
335
     *
336
     * @return string the rendering result
337
     */
338 10
    private function renderItem(array $item, int $index): string
339
    {
340 10
        if (!array_key_exists('content', $item)) {
341 1
            throw new RuntimeException('The "content" option is required.');
342
        }
343
344 9
        $header = $this->renderHeader($item, $index);
345 9
        $collapse = $this->renderCollapse($item, $index);
346
347 8
        return $header . $collapse;
348
    }
349
350
    /**
351
     * Render collapse header
352
     */
353 9
    private function renderHeader(array $item, int $index): string
354
    {
355 9
        $options = ArrayHelper::getValue($item, 'headerOptions', $this->headerOptions);
356 9
        $tag = ArrayHelper::remove($options, 'tag', 'h2');
357 9
        $options['id'] = $this->getHeaderId($item, $index);
358 9
        $toggle = $this->renderToggle($item, $index);
359
360 9
        Html::addCssClass($options, ['widget' => 'accordion-header']);
361
362 9
        return Html::tag($tag, $toggle, $options)
363 9
            ->encode(false)
364 9
            ->render();
365
    }
366
367
    /**
368
     * Render collapse switcher
369
     */
370 9
    private function renderToggle(array $item, int $index): string
371
    {
372 9
        $label = $item['label'];
373 9
        $expand = $item['expand'] ?? false;
374 9
        $collapseId = $this->getCollapseId($item, $index);
375
376 9
        $options = array_merge(
377 9
            [
378 9
                'data-bs-toggle' => 'collapse',
379 9
                'aria-expanded' => $expand ? 'true' : 'false',
380 9
                'aria-controls' => $collapseId,
381 9
            ],
382 9
            $item['toggleOptions'] ?? $this->itemToggleOptions
383 9
        );
384 9
        $tag = ArrayHelper::remove($options, 'tag', 'button');
385 9
        $encode = ArrayHelper::remove($options, 'encode', $this->encodeLabels);
386
387 9
        Html::addCssClass($options, ['accordion-button']);
388
389 9
        if (!$expand) {
390 8
            Html::addCssClass($options, ['collapsed']);
391
        }
392
393 9
        if ($tag === 'a') {
394 1
            $options['href'] = '#' . $collapseId;
395
        } else {
396 8
            $options['data-bs-target'] = '#' . $collapseId;
397
398 8
            if ($tag === 'button' && !isset($options['type'])) {
399 8
                $options['type'] = 'button';
400
            }
401
        }
402
403 9
        return Html::tag($tag, $label, $options)
404 9
            ->encode($encode)
405 9
            ->render();
406
    }
407
408
    /**
409
     * Render collapse item
410
     */
411 9
    private function renderCollapse(array $item, int $index): string
412
    {
413 9
        $expand = $item['expand'] ?? false;
414 9
        $options = $item['contentOptions'] ?? $this->contentOptions;
415 9
        $tag = ArrayHelper::remove($options, 'tag', 'div');
416 9
        $body = $this->renderBody($item);
417 8
        $options['id'] = $this->getCollapseId($item, $index);
418
419 8
        Html::addCssClass($options, ['accordion-collapse collapse']);
420
421 8
        if ($expand) {
422 7
            Html::addCssClass($options, ['show']);
423
        }
424
425 8
        if (!isset($options['aria-label'], $options['aria-labelledby'])) {
426 8
            $options['aria-labelledby'] = $this->getHeaderId($item, $index);
427
        }
428
429 8
        if ($this->autoCloseItems) {
430 8
            $options['data-bs-parent'] = '#' . $this->getId();
431
        }
432
433 8
        return Html::tag($tag, $body, $options)
434 8
            ->encode(false)
435 8
            ->render();
436
    }
437
438
    /**
439
     * Render collapse body
440
     */
441 9
    private function renderBody(array $item): string
442
    {
443 9
        $items = '';
444
445 9
        if ($this->isStringableObject($item['content'])) {
446 1
            $content = [$item['content']];
447
        } else {
448 9
            $content = (array) $item['content'];
449
        }
450
451 9
        foreach ($content as $value) {
452 9
            if (!is_string($value) && !is_numeric($value) && !$this->isStringableObject($value)) {
453 1
                throw new RuntimeException('The "content" option should be a string, array or object.');
454
            }
455
456 8
            $items .= $value;
457
        }
458
459 8
        return Html::div($items, ['class' => 'accordion-body'])
460 8
            ->encode($this->encodeTags)
461 8
            ->render();
462
    }
463
464 9
    private function isStringableObject(mixed $value): bool
465
    {
466 9
        return is_object($value) && method_exists($value, '__toString');
467
    }
468
}
469