Passed
Push — master ( 5f6e57...34e66c )
by Sergei
05:58 queued 03:17
created

Accordion::renderToggle()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 36
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 22
c 0
b 0
f 0
nc 6
nop 2
dl 0
loc 36
ccs 25
cts 25
cp 1
crap 6
rs 8.9457

1 Method

Rating   Name   Duplication   Size   Complexity  
A Accordion::renderCollapse() 0 30 3
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 $headerOptions = [];
88
    private array $toggleOptions = [];
89
    private array $contentOptions = [];
90
    private array $bodyOptions = [];
91
    private array $options = [];
92
    private bool $flush = false;
93
94 16
    public function getId(?string $suffix = '-accordion'): ?string
95
    {
96 16
        return $this->options['id'] ?? parent::getId($suffix);
97
    }
98
99
    /**
100
     * @throws JsonException
101
     * @return string
102
     */
103 16
    public function render(): string
104
    {
105 16
        $options = $this->options;
106 16
        $options['id'] = $this->getId();
107 16
        Html::addCssClass($options, ['widget' => 'accordion']);
108
109 16
        if ($this->flush) {
110 1
            Html::addCssClass($options, ['flush' => 'accordion-flush']);
111
        }
112
113 16
        if ($this->theme) {
114
            $options['data-bs-theme'] = $this->theme;
115
        }
116
117 16
        return Html::div($this->renderItems(), $options)
118 16
            ->encode($this->encodeTags)
119 16
            ->render();
120
    }
121
122
    /**
123
     * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect.
124
     *
125
     * Set this to `false` to allow keeping multiple items open at once.
126
     */
127 1
    public function allowMultipleOpenedItems(): self
128
    {
129 1
        $new = clone $this;
130 1
        $new->autoCloseItems = false;
131
132 1
        return $new;
133
    }
134
135
    /**
136
     * When tags Labels HTML should not be encoded.
137
     */
138 1
    public function withoutEncodeLabels(): self
139
    {
140 1
        $new = clone $this;
141 1
        $new->encodeLabels = false;
142
143 1
        return $new;
144
    }
145
146
    /**
147
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
148
     *
149
     * - label: string, required, the group header label.
150
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
151
     *   `$this->encodeLabels` param.
152
     * - content: array|string|object, required, the content (HTML) of the group
153
     * - options: array, optional, the HTML attributes of the group
154
     * - contentOptions: optional, the HTML attributes of the group's content
155
     *
156
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
157
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
158
     * above.
159
     *
160
     * For example:
161
     *
162
     * ```php
163
     * echo Accordion::widget()
164
     *     ->items(
165
     *         [
166
     *             [
167
     *                 'Introduction' => 'This is the first collapsible menu',
168
     *                 'Second panel' => [
169
     *                     'content' => 'This is the second collapsible menu',
170
     *                 ],
171
     *             ],
172
     *             [
173
     *                 'label' => 'Third panel',
174
     *                 'content' => 'This is the third collapsible menu',
175
     *             ],
176
     *         ],
177
     *     );
178
     * ```
179
     */
180 16
    public function items(array $value): self
181
    {
182 16
        $new = clone $this;
183 16
        $new->items = $value;
184 16
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $this->defaultExpand, $new->items);
185
186 16
        return $new;
187
    }
188
189
    /**
190
     * Set expand property for items without it
191
     */
192 4
    public function defaultExpand(?bool $default): self
193
    {
194 4
        if ($default === $this->defaultExpand) {
195
            return $this;
196
        }
197
198 4
        $new = clone $this;
199 4
        $new->defaultExpand = $default;
200 4
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $new->defaultExpand, $new->items);
201
202 4
        return $new;
203
    }
204
205
    /**
206
     * Options for each header if not present in item
207
     */
208 1
    public function headerOptions(array $options): self
209
    {
210 1
        $new = clone $this;
211 1
        $new->headerOptions = $options;
212
213 1
        return $new;
214
    }
215
216
    /**
217
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
218
     *
219
     * For example:
220
     *
221
     * ```php
222
     * [
223
     *     'tag' => 'div',
224
     *     'class' => 'custom-toggle',
225
     * ]
226
     * ```
227
     */
228 1
    public function toggleOptions(array $options): self
229
    {
230 1
        $new = clone $this;
231 1
        $new->toggleOptions = $options;
232
233 1
        return $new;
234
    }
235
236
    /**
237
     * Content options for items if not present in current
238
     */
239 1
    public function contentOptions(array $options): self
240
    {
241 1
        $new = clone $this;
242 1
        $new->contentOptions = $options;
243
244 1
        return $new;
245
    }
246
247
    /**
248
     * The HTML attributes for the widget container tag. The following special options are recognized.
249
     *
250
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
251
     */
252 1
    public function options(array $value): self
253
    {
254 1
        $new = clone $this;
255 1
        $new->options = $value;
256
257 1
        return $new;
258
    }
259
260 1
    public function bodyOptions(array $options): self
261
    {
262 1
        $new = clone $this;
263 1
        $new->bodyOptions = $options;
264
265 1
        return $new;
266
    }
267
268
    /**
269
     * Remove the default background-color, some borders, and some rounded corners to render accordions
270
     * edge-to-edge with their parent container.
271
     *
272
     * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush
273
     */
274 1
    public function flush(): self
275
    {
276 1
        $new = clone $this;
277 1
        $new->flush = true;
278
279 1
        return $new;
280
    }
281
282
    /**
283
     * Renders collapsible items as specified on {@see items}.
284
     *
285
     * @throws JsonException|RuntimeException
286
     *
287
     * @return string the rendering result
288
     */
289 16
    private function renderItems(): string
290
    {
291 16
        $items = [];
292 16
        $index = 0;
293 16
        $expanded = in_array(true, $this->expands, true);
294 16
        $allClose = !$expanded && count($this->items) === count(array_filter($this->expands, static fn ($expand) => $expand === false));
295
296 16
        foreach ($this->items as $item) {
297 16
            if (!is_array($item)) {
298 1
                $item = ['content' => $item];
299
            }
300
301 16
            if ($allClose === false && $expanded === false && $index === 0) {
302 11
                $item['expand'] = true;
303
            }
304
305 16
            if (!array_key_exists('label', $item)) {
306 3
                throw new RuntimeException('The "label" option is required.');
307
            }
308
309 13
            $options = ArrayHelper::getValue($item, 'options', []);
310 13
            $item = $this->renderItem($item);
311
312 11
            Html::addCssClass($options, ['panel' => 'accordion-item']);
313
314 11
            $items[] = Html::div($item, $options)
315 11
                ->encode(false)
316 11
                ->render();
317
318 11
            $index++;
319
        }
320
321 11
        return implode('', $items);
322
    }
323
324
    /**
325
     * Renders a single collapsible item group.
326
     *
327
     * @param array $item a single item from {@see items}
328
     * @param int $index the item index as each item group content must have an id
329
     *
330
     * @throws JsonException|RuntimeException
331
     *
332
     * @return string the rendering result
333
     */
334 13
    private function renderItem(array $item): string
335
    {
336 13
        if (!array_key_exists('content', $item)) {
337 1
            throw new RuntimeException('The "content" option is required.');
338
        }
339
340 12
        $collapse = $this->renderCollapse($item);
341 11
        $header = $this->renderHeader($collapse, ArrayHelper::getValue($item, 'headerOptions'));
342
343 11
        return $header . $collapse->render();
344
    }
345
346
    /**
347
     * Render collapse header
348
     */
349 11
    private function renderHeader(Collapse $collapse, ?array $headerOptions): string
350
    {
351 11
        $options = $headerOptions ?? $this->headerOptions;
352 11
        $tag = ArrayHelper::remove($options, 'tag', 'h2');
353
354 11
        Html::addCssClass($options, ['widget' => 'accordion-header']);
355
356 11
        return Html::tag($tag, $collapse->renderToggle(), $options)
357 11
            ->encode(false)
358 11
            ->render();
359
    }
360
361
    /**
362
     * Render collapse item
363
     */
364 12
    private function renderCollapse(array $item): Collapse
365
    {
366 12
        $expand = $item['expand'] ?? false;
367 12
        $options = $item['contentOptions'] ?? $this->contentOptions;
368 12
        $toggleOptions = $item['toggleOptions'] ?? $this->toggleOptions;
369 12
        $bodyOptions = $item['bodyOptions'] ?? $this->bodyOptions;
370
371 12
        $toggleOptions['encode'] ??= $this->encodeLabels;
372 12
        $bodyOptions['encode'] ??= $this->encodeTags;
373
374 12
        Html::addCssClass($options, ['accordion-collapse']);
375 12
        Html::addCssClass($toggleOptions, ['accordion-button']);
376 12
        Html::addCssClass($bodyOptions, ['widget' => 'accordion-body']);
377
378 12
        if (!$expand) {
379 11
            Html::addCssClass($toggleOptions, ['collapsed']);
380
        }
381
382 12
        if ($this->autoCloseItems) {
383 12
            $options['data-bs-parent'] = '#' . $this->getId();
384
        }
385
386 12
        return Collapse::widget()
387 12
            ->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

387
            ->/** @scrutinizer ignore-call */ withToggleLabel($item['label'])
Loading history...
388 12
            ->withToggleOptions($toggleOptions)
389 12
            ->withOptions($options)
390 12
            ->withContent($this->renderBody($item))
391 12
            ->withBodyOptions($bodyOptions)
392 12
            ->withCollapsed($expand)
393 12
            ->withToggle(false);
394
    }
395
396
    /**
397
     * Render collapse body
398
     */
399 12
    private function renderBody(array $item): string
400
    {
401 12
        $items = '';
402
403 12
        if ($this->isStringableObject($item['content'])) {
404 1
            $content = [$item['content']];
405
        } else {
406 12
            $content = (array) $item['content'];
407
        }
408
409 12
        foreach ($content as $value) {
410 12
            if (!is_string($value) && !is_numeric($value) && !$this->isStringableObject($value)) {
411 1
                throw new RuntimeException('The "content" option should be a string, array or object.');
412
            }
413
414 11
            $items .= $value;
415
        }
416
417 11
        return $items;
418
    }
419
420 12
    private function isStringableObject(mixed $value): bool
421
    {
422 12
        return $value instanceof Stringable;
423
    }
424
}
425