Passed
Pull Request — master (#85)
by Albert
15:45 queued 13:27
created

Accordion::options()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 6
ccs 4
cts 4
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Bootstrap5;
6
7
use JsonException;
8
use Stringable;
9
use RuntimeException;
10
use Yiisoft\Arrays\ArrayHelper;
11
use Yiisoft\Html\Html;
12
13
use function array_key_exists;
14
use function array_merge;
15
use function implode;
16
use function is_array;
17
use function is_numeric;
18
use function is_string;
19
20
/**
21
 * Accordion renders an accordion bootstrap JavaScript component.
22
 *
23
 * For example:
24
 *
25
 * ```php
26
 * echo Accordion::widget()
27
 *     ->items([
28
 *         [
29
 *             'label' => 'Accordion Item #1',
30
 *             'content' => [
31
 *                 'This is the first items accordion body. It is shown by default, until the collapse plugin ' .
32
 *                 'the appropriate classes that we use to style each element. These classes control the ' .
33
 *                 'overall appearance, as well as the showing and hiding via CSS transitions. You can  ' .
34
 *                 'modify any of this with custom CSS or overriding our default variables. Its also worth ' .
35
 *                 'noting that just about any HTML can go within the .accordion-body, though the transition ' .
36
 *                 'does limit overflow.',
37
 *             ],
38
 *         ],
39
 *         [
40
 *             'label' => 'Accordion Item #2',
41
 *             'content' => '<strong>This is the second items accordion body.</strong> It is hidden by default, ' .
42
 *                 'until the collapse plugin adds the appropriate classes that we use to style each element. ' .
43
 *                 'These classes control the overall appearance, as well as the showing and hiding via CSS ' .
44
 *                 'transitions. You can modify any of this with custom CSS or overriding our default ' .
45
 *                 'variables. Its also worth noting that just about any HTML can go within the ' .
46
 *                 '<code>.accordion-body</code>, though the transition does limit overflow.',
47
 *             'contentOptions' => [
48
 *                 'class' => 'testContentOptions',
49
 *             ],
50
 *             'options' => [
51
 *                 'class' => 'testClass',
52
 *                 'id' => 'testId',
53
 *             ],
54
 *         ],
55
 *         [
56
 *             'label' => '<b>Accordion Item #3</b>',
57
 *             'content' => [
58
 *                 '<b>test content1</b>',
59
 *                 '<strong>This is the third items accordion body.</strong> It is hidden by default, until the ' .
60
 *                 'collapse plugin adds the appropriate classes that we use to style each element. These ' .
61
 *                 'classes control the overall appearance, as well as the showing and hiding via CSS ' .
62
 *                 'transitions. You can modify any of this with custom CSS or overriding our default ' .
63
 *                 'variables. Its also worth noting that just about any HTML can go within the ' .
64
 *                 '<code>.accordion-body</code>, though the transition does limit overflow.',
65
 *             ],
66
 *             'contentOptions' => [
67
 *                 'class' => 'testContentOptions2',
68
 *             ],
69
 *             'options' => [
70
 *                 'class' => 'testClass2',
71
 *                 'id' => 'testId2',
72
 *             ],
73
 *             'encode' => false,
74
 *         ],
75
 *     ]);
76
 * ```
77
 *
78
 * @link https://getbootstrap.com/docs/5.0/components/accordion/
79
 */
80
final class Accordion extends Widget
81
{
82
    private array $items = [];
83
    private array $expands = [];
84
    private ?bool $defaultExpand = null;
85
    private bool $encodeLabels = true;
86
    private bool $encodeTags = false;
87
    private bool $autoCloseItems = true;
88
    private array $headerOptions = [];
89
    private array $itemToggleOptions = [];
90
    private array $contentOptions = [];
91
    private array $options = [];
92
    private bool $flush = false;
93
94 13
    public function getId(?string $suffix = '-accordion'): ?string
95
    {
96 13
        return $this->options['id'] ?? parent::getId($suffix);
97
    }
98
99 9
    private function getCollapseId(array $item, int $index): string
100
    {
101 9
        return ArrayHelper::getValueByPath($item, ['contentOptions', 'id'], $this->getId() . '-collapse' . $index);
102
    }
103
104 9
    private function getHeaderId(array $item, int $index): string
105
    {
106 9
        return ArrayHelper::getValueByPath($item, ['headerOptions', 'id'], $this->getCollapseId($item, $index) . '-heading');
107
    }
108
109 13
    public function beforeRun(): bool
110
    {
111 13
        Html::addCssClass($this->options, ['widget' => 'accordion']);
112
113 13
        if ($this->flush) {
114 1
            Html::addCssClass($this->options, ['flush' => 'accordion-flush']);
115
        }
116
117 13
        if (!isset($this->options['id'])) {
118 13
            $this->options['id'] = $this->getId();
119
        }
120
121 13
        return parent::beforeRun();
122
    }
123
124 13
    protected function run(): string
125
    {
126 13
        return Html::div($this->renderItems(), $this->options)
127 8
            ->encode($this->encodeTags)
128 8
            ->render();
129
    }
130
131
    /**
132
     * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect.
133
     *
134
     * Set this to `false` to allow keeping multiple items open at once.
135
     *
136
     * @return self
137
     */
138 1
    public function allowMultipleOpenedItems(): self
139
    {
140 1
        $new = clone $this;
141 1
        $new->autoCloseItems = false;
142
143 1
        return $new;
144
    }
145
146
    /**
147
     * When tags Labels HTML should not be encoded.
148
     *
149
     * @return self
150
     */
151 1
    public function withoutEncodeLabels(): self
152
    {
153 1
        $new = clone $this;
154 1
        $new->encodeLabels = false;
155
156 1
        return $new;
157
    }
158
159
    /**
160
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
161
     *
162
     * - label: string, required, the group header label.
163
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
164
     *   `$this->encodeLabels` param.
165
     * - content: array|string|object, required, the content (HTML) of the group
166
     * - options: array, optional, the HTML attributes of the group
167
     * - contentOptions: optional, the HTML attributes of the group's content
168
     *
169
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
170
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
171
     * above.
172
     *
173
     * For example:
174
     *
175
     * ```php
176
     * echo Accordion::widget()
177
     *     ->items(
178
     *         [
179
     *             [
180
     *                 'Introduction' => 'This is the first collapsible menu',
181
     *                 'Second panel' => [
182
     *                     'content' => 'This is the second collapsible menu',
183
     *                 ],
184
     *             ],
185
     *             [
186
     *                 'label' => 'Third panel',
187
     *                 'content' => 'This is the third collapsible menu',
188
     *             ],
189
     *         ],
190
     *     );
191
     * ```
192
     *
193
     * @param array $value
194
     *
195
     * @return self
196
     */
197 13
    public function items(array $value): self
198
    {
199 13
        $new = clone $this;
200 13
        $new->items = $value;
201 13
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $this->defaultExpand, $new->items);
202
203 13
        return $new;
204
    }
205
206
    /**
207
     * Set expand property for items without it
208
     *
209
     * @param bool|null $default
210
     *
211
     * @return self
212
     */
213 1
    public function defaultExpand(?bool $default): self
214
    {
215 1
        if ($default === $this->defaultExpand) {
216
            return $this;
217
        }
218
219 1
        $new = clone $this;
220 1
        $new->defaultExpand = $default;
221 1
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $new->defaultExpand, $new->items);
222
223 1
        return $new;
224
    }
225
226
    /**
227
     * Options for each header if not present in item
228
     *
229
     * @param array $options
230
     *
231
     * @return self
232
     */
233
    public function headerOptions(array $options): self
234
    {
235
        $new = clone $this;
236
        $new->headerOptions = $options;
237
238
        return $new;
239
    }
240
241
    /**
242
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
243
     *
244
     * For example:
245
     *
246
     * ```php
247
     * [
248
     *     'tag' => 'div',
249
     *     'class' => 'custom-toggle',
250
     * ]
251
     * ```
252
     *
253
     * @param array $value
254
     *
255
     * @return self
256
     */
257 1
    public function itemToggleOptions(array $value): self
258
    {
259 1
        $new = clone $this;
260 1
        $new->itemToggleOptions = $value;
261
262 1
        return $new;
263
    }
264
265
    /**
266
     * Content options for items if not present in current
267
     *
268
     * @param array $options
269
     *
270
     * @return self
271
     */
272
    public function contentOptions(array $options): self
273
    {
274
        $new = clone $this;
275
        $new->contentOptions = $options;
276
277
        return $new;
278
    }
279
280
    /**
281
     * The HTML attributes for the widget container tag. The following special options are recognized.
282
     *
283
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
284
     *
285
     * @param array $value
286
     *
287
     * @return self
288
     */
289 1
    public function options(array $value): self
290
    {
291 1
        $new = clone $this;
292 1
        $new->options = $value;
293
294 1
        return $new;
295
    }
296
297
    /**
298
     * Remove the default background-color, some borders, and some rounded corners to render accordions
299
     * edge-to-edge with their parent container.
300
     *
301
     * @return self
302
     *
303
     * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush
304
     */
305 1
    public function flush(): self
306
    {
307 1
        $new = clone $this;
308 1
        $new->flush = true;
309
310 1
        return $new;
311
    }
312
313
    /**
314
     * Renders collapsible items as specified on {@see items}.
315
     *
316
     * @throws JsonException|RuntimeException
317
     *
318
     * @return string the rendering result
319
     */
320 13
    private function renderItems(): string
321
    {
322 13
        $items = [];
323 13
        $index = 0;
324 13
        $expanded = in_array(true, $this->expands, true);
325 13
        $allClose = !$expanded && count($this->items) === count(array_filter($this->expands, fn ($expand) => $expand === false));
326
327 13
        foreach ($this->items as $item) {
328 13
            if (!is_array($item)) {
329 1
                $item = ['content' => $item];
330
            }
331
332 13
            if ($allClose === false && $expanded === false && $index === 0) {
333 11
                $item['expand'] = true;
334
            }
335
336 13
            if (!array_key_exists('label', $item)) {
337 3
                throw new RuntimeException('The "label" option is required.');
338
            }
339
340 10
            $options = ArrayHelper::getValue($item, 'options', []);
341 10
            $item = $this->renderItem($item, $index++);
342
343 8
            Html::addCssClass($options, ['panel' => 'accordion-item']);
344
345 8
            $items[] = Html::div($item, $options)->encode(false)->render();
346
        }
347
348 8
        return implode('', $items);
349
    }
350
351
    /**
352
     * Renders a single collapsible item group.
353
     *
354
     * @param string $label a label of the item group {@see items}
355
     * @param array $item a single item from {@see items}
356
     * @param int $index the item index as each item group content must have an id
357
     *
358
     * @throws JsonException|RuntimeException
359
     *
360
     * @return string the rendering result
361
     */
362 10
    private function renderItem(array $item, int $index): string
363
    {
364 10
        if (!array_key_exists('content', $item)) {
365 1
            throw new RuntimeException('The "content" option is required.');
366
        }
367
368 9
        $header = $this->renderHeader($item, $index);
369 9
        $collapse = $this->renderCollapse($item, $index);
370
371 8
        return $header . $collapse;
372
    }
373
374
    /**
375
     * Render collapse header
376
     *
377
     * @param array $item
378
     * @param int $index
379
     *
380
     * @return string
381
     */
382 9
    private function renderHeader(array $item, int $index): string
383
    {
384 9
        $options = ArrayHelper::getValue($item, 'headerOptions', $this->headerOptions);
385 9
        $tag = ArrayHelper::remove($options, 'tag', 'h2');
386 9
        $options['id'] = $this->getHeaderId($item, $index);
387 9
        $toggle = $this->renderToggle($item, $index);
388
389 9
        Html::addCssClass($options, ['widget' => 'accordion-header']);
390
391 9
        return Html::tag($tag, $toggle, $options)->encode(false)->render();
392
    }
393
394
    /**
395
     * Render collapse switcher
396
     *
397
     * @param array $item
398
     * @param int $index
399
     *
400
     * @return string
401
     */
402 9
    private function renderToggle(array $item, int $index): string
403
    {
404 9
        $label = $item['label'];
405 9
        $expand = $item['expand'] ?? false;
406 9
        $collapseId = $this->getCollapseId($item, $index);
407
408 9
        $options = array_merge(
409
            [
410 9
                'data-bs-toggle' => 'collapse',
411 9
                'aria-expanded' => $expand ? 'true' : 'false',
412
                'aria-controls' => $collapseId,
413
            ],
414 9
            $item['toggleOptions'] ?? $this->itemToggleOptions
415
        );
416 9
        $tag = ArrayHelper::remove($options, 'tag', 'button');
417 9
        $encode = ArrayHelper::remove($options, 'encode', $this->encodeLabels);
418
419 9
        Html::addCssClass($options, ['accordion-button']);
420
421 9
        if (!$expand) {
422 8
            Html::addCssClass($options, ['collapsed']);
423
        }
424
425 9
        if ($tag === 'a') {
426 1
            $options['href'] = '#' . $collapseId;
427
        } else {
428 8
            $options['data-bs-target'] = '#' . $collapseId;
429
430 8
            if ($tag === 'button' && !isset($options['type'])) {
431 8
                $options['type'] = 'button';
432
            }
433
        }
434
435 9
        return Html::tag($tag, $label, $options)->encode($encode)->render();
436
    }
437
438
    /**
439
     * Render collapse item
440
     *
441
     * @param array $item
442
     * @param int $index
443
     *
444
     * @return string
445
     */
446 9
    private function renderCollapse(array $item, int $index): string
447
    {
448 9
        $expand = $item['expand'] ?? false;
449 9
        $options = $item['contentOptions'] ?? $this->contentOptions;
450 9
        $tag = ArrayHelper::remove($options, 'tag', 'div');
451 9
        $body = $this->renderBody($item);
452 8
        $options['id'] = $this->getCollapseId($item, $index);
453
454 8
        Html::addCssClass($options, ['accordion-collapse collapse']);
455
456 8
        if ($expand) {
457 7
            Html::addCssClass($options, ['show']);
458
        }
459
460 8
        if (!isset($options['aria-label'], $options['aria-labelledby'])) {
461 8
            $options['aria-labelledby'] = $this->getHeaderId($item, $index);
462
        }
463
464 8
        if ($this->autoCloseItems) {
465 8
            $options['data-bs-parent'] = '#' . $this->getId();
466
        }
467
468 8
        return Html::tag($tag, $body, $options)->encode(false)->render();
469
    }
470
471
    /**
472
     * Render collapse body
473
     *
474
     * @param array $item
475
     *
476
     * @return string
477
     */
478 9
    private function renderBody(array $item): string
479
    {
480 9
        $items = '';
481
482 9
        if (is_a($item['content'], Stringable::class)) {
483
            $content = [$item['content']];
484
        } else {
485 9
            $content = (array) $item['content'];
486
        }
487
488 9
        foreach ($content as $value) {
489 9
            if (!is_string($value) && !is_numeric($value) && !is_a($value, Stringable::class)) {
490 1
                throw new RuntimeException('The "content" option should be a string, array or object.');
491
            }
492
493 8
            $items .= Html::div((string) $value, ['class' => 'accordion-body'])
494 8
                    ->encode($this->encodeTags)
495 8
                    ->render();
496
        }
497
498 8
        return $items;
499
    }
500
}
501