Accordion::renderBody()   A
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 10
c 0
b 0
f 0
nc 6
nop 1
dl 0
loc 19
ccs 10
cts 10
cp 1
crap 6
rs 9.2222
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
The method withToggleLabel() does not exist on Yiisoft\Widget\Widget. It seems like you code against a sub-type of Yiisoft\Widget\Widget such as Yiisoft\Yii\Bootstrap5\AbstractToggleWidget. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

397
            ->/** @scrutinizer ignore-call */ withToggleLabel($item['label'])
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