Passed
Pull Request — master (#127)
by Sergei
02:52
created

Accordion   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 344
Duplicated Lines 0 %

Test Coverage

Coverage 98.39%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 119
dl 0
loc 344
ccs 122
cts 124
cp 0.9839
rs 9.36
c 1
b 0
f 0
wmc 38

18 Methods

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

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