Passed
Push — master ( d8841e...6d6caa )
by Sergei
02:55
created

Accordion   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 388
Duplicated Lines 0 %

Test Coverage

Coverage 93.62%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 48
eloc 135
c 1
b 0
f 0
dl 0
loc 388
ccs 132
cts 141
cp 0.9362
rs 8.5599

21 Methods

Rating   Name   Duplication   Size   Complexity  
A getId() 0 3 1
A getCollapseId() 0 3 1
A run() 0 5 1
A getHeaderId() 0 3 1
A beforeRun() 0 13 3
A withoutEncodeLabels() 0 6 1
A flush() 0 6 1
A headerOptions() 0 6 1
A contentOptions() 0 6 1
B renderItems() 0 31 8
A defaultExpand() 0 11 3
A allowMultipleOpenedItems() 0 6 1
A isStringableObject() 0 3 2
A renderBody() 0 21 6
A items() 0 7 2
A renderHeader() 0 12 1
B renderToggle() 0 36 6
A renderCollapse() 0 25 4
A itemToggleOptions() 0 6 1
A options() 0 6 1
A renderItem() 0 10 2

How to fix   Complexity   

Complex Class

Complex classes like Accordion often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Accordion, and based on these observations, apply Extract Interface, too.

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