Passed
Pull Request — master (#94)
by Sergei
03:41
created

Accordion::getId()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
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
12
use function array_key_exists;
13
use function array_merge;
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 $itemToggleOptions = [];
89
    private array $contentOptions = [];
90
    private array $options = [];
91
    private bool $flush = false;
92
93 13
    public function getId(?string $suffix = '-accordion'): ?string
94
    {
95 13
        return $this->options['id'] ?? parent::getId($suffix);
96
    }
97
98 9
    private function getCollapseId(array $item, int $index): string
99
    {
100 9
        return ArrayHelper::getValueByPath($item, ['contentOptions', 'id'], $this->getId() . '-collapse' . $index);
101
    }
102
103 9
    private function getHeaderId(array $item, int $index): string
104
    {
105 9
        return ArrayHelper::getValueByPath($item, ['headerOptions', 'id'], $this->getCollapseId($item, $index) . '-heading');
106
    }
107
108 13
    public function beforeRun(): bool
109
    {
110 13
        Html::addCssClass($this->options, ['widget' => 'accordion']);
111
112 13
        if ($this->flush) {
113 1
            Html::addCssClass($this->options, ['flush' => 'accordion-flush']);
114
        }
115
116 13
        if (!isset($this->options['id'])) {
117 13
            $this->options['id'] = $this->getId();
118
        }
119
120 13
        return parent::beforeRun();
121
    }
122
123 13
    protected function run(): string
124
    {
125 13
        return Html::div($this->renderItems(), $this->options)
126 8
            ->encode($this->encodeTags)
127 8
            ->render();
128
    }
129
130
    /**
131
     * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect.
132
     *
133
     * Set this to `false` to allow keeping multiple items open at once.
134
     *
135
     * @return self
136
     */
137 1
    public function allowMultipleOpenedItems(): self
138
    {
139 1
        $new = clone $this;
140 1
        $new->autoCloseItems = false;
141
142 1
        return $new;
143
    }
144
145
    /**
146
     * When tags Labels HTML should not be encoded.
147
     *
148
     * @return self
149
     */
150 1
    public function withoutEncodeLabels(): self
151
    {
152 1
        $new = clone $this;
153 1
        $new->encodeLabels = false;
154
155 1
        return $new;
156
    }
157
158
    /**
159
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
160
     *
161
     * - label: string, required, the group header label.
162
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
163
     *   `$this->encodeLabels` param.
164
     * - content: array|string|object, required, the content (HTML) of the group
165
     * - options: array, optional, the HTML attributes of the group
166
     * - contentOptions: optional, the HTML attributes of the group's content
167
     *
168
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
169
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
170
     * above.
171
     *
172
     * For example:
173
     *
174
     * ```php
175
     * echo Accordion::widget()
176
     *     ->items(
177
     *         [
178
     *             [
179
     *                 'Introduction' => 'This is the first collapsible menu',
180
     *                 'Second panel' => [
181
     *                     'content' => 'This is the second collapsible menu',
182
     *                 ],
183
     *             ],
184
     *             [
185
     *                 'label' => 'Third panel',
186
     *                 'content' => 'This is the third collapsible menu',
187
     *             ],
188
     *         ],
189
     *     );
190
     * ```
191
     *
192
     * @param array $value
193
     *
194
     * @return self
195
     */
196 13
    public function items(array $value): self
197
    {
198 13
        $new = clone $this;
199 13
        $new->items = $value;
200 13
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $this->defaultExpand, $new->items);
201
202 13
        return $new;
203
    }
204
205
    /**
206
     * Set expand property for items without it
207
     *
208
     * @param bool|null $default
209
     *
210
     * @return self
211
     */
212 1
    public function defaultExpand(?bool $default): self
213
    {
214 1
        if ($default === $this->defaultExpand) {
215
            return $this;
216
        }
217
218 1
        $new = clone $this;
219 1
        $new->defaultExpand = $default;
220 1
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $new->defaultExpand, $new->items);
221
222 1
        return $new;
223
    }
224
225
    /**
226
     * Options for each header if not present in item
227
     *
228
     * @param array $options
229
     *
230
     * @return self
231
     */
232
    public function headerOptions(array $options): self
233
    {
234
        $new = clone $this;
235
        $new->headerOptions = $options;
236
237
        return $new;
238
    }
239
240
    /**
241
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
242
     *
243
     * For example:
244
     *
245
     * ```php
246
     * [
247
     *     'tag' => 'div',
248
     *     'class' => 'custom-toggle',
249
     * ]
250
     * ```
251
     *
252
     * @param array $value
253
     *
254
     * @return self
255
     */
256 1
    public function itemToggleOptions(array $value): self
257
    {
258 1
        $new = clone $this;
259 1
        $new->itemToggleOptions = $value;
260
261 1
        return $new;
262
    }
263
264
    /**
265
     * Content options for items if not present in current
266
     *
267
     * @param array $options
268
     *
269
     * @return self
270
     */
271
    public function contentOptions(array $options): self
272
    {
273
        $new = clone $this;
274
        $new->contentOptions = $options;
275
276
        return $new;
277
    }
278
279
    /**
280
     * The HTML attributes for the widget container tag. The following special options are recognized.
281
     *
282
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
283
     *
284
     * @param array $value
285
     *
286
     * @return self
287
     */
288 1
    public function options(array $value): self
289
    {
290 1
        $new = clone $this;
291 1
        $new->options = $value;
292
293 1
        return $new;
294
    }
295
296
    /**
297
     * Remove the default background-color, some borders, and some rounded corners to render accordions
298
     * edge-to-edge with their parent container.
299
     *
300
     * @return self
301
     *
302
     * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush
303
     */
304 1
    public function flush(): self
305
    {
306 1
        $new = clone $this;
307 1
        $new->flush = true;
308
309 1
        return $new;
310
    }
311
312
    /**
313
     * Renders collapsible items as specified on {@see items}.
314
     *
315
     * @throws JsonException|RuntimeException
316
     *
317
     * @return string the rendering result
318
     */
319 13
    private function renderItems(): string
320
    {
321 13
        $items = [];
322 13
        $index = 0;
323 13
        $expanded = in_array(true, $this->expands, true);
324 13
        $allClose = !$expanded && count($this->items) === count(array_filter($this->expands, fn ($expand) => $expand === false));
325
326 13
        foreach ($this->items as $item) {
327 13
            if (!is_array($item)) {
328 1
                $item = ['content' => $item];
329
            }
330
331 13
            if ($allClose === false && $expanded === false && $index === 0) {
332 11
                $item['expand'] = true;
333
            }
334
335 13
            if (!array_key_exists('label', $item)) {
336 3
                throw new RuntimeException('The "label" option is required.');
337
            }
338
339 10
            $options = ArrayHelper::getValue($item, 'options', []);
340 10
            $item = $this->renderItem($item, $index++);
341
342 8
            Html::addCssClass($options, ['panel' => 'accordion-item']);
343
344 8
            $items[] = Html::div($item, $options)
345 8
                ->encode(false)
346 8
                ->render();
347
        }
348
349 8
        return implode('', $items);
350
    }
351
352
    /**
353
     * Renders a single collapsible item group.
354
     *
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)
392 9
            ->encode(false)
393 9
            ->render();
394
    }
395
396
    /**
397
     * Render collapse switcher
398
     *
399
     * @param array $item
400
     * @param int $index
401
     *
402
     * @return string
403
     */
404 9
    private function renderToggle(array $item, int $index): string
405
    {
406 9
        $label = $item['label'];
407 9
        $expand = $item['expand'] ?? false;
408 9
        $collapseId = $this->getCollapseId($item, $index);
409
410 9
        $options = array_merge(
411
            [
412
                'data-bs-toggle' => 'collapse',
413 9
                'aria-expanded' => $expand ? 'true' : 'false',
414
                'aria-controls' => $collapseId,
415
            ],
416 9
            $item['toggleOptions'] ?? $this->itemToggleOptions
417
        );
418 9
        $tag = ArrayHelper::remove($options, 'tag', 'button');
419 9
        $encode = ArrayHelper::remove($options, 'encode', $this->encodeLabels);
420
421 9
        Html::addCssClass($options, ['accordion-button']);
422
423 9
        if (!$expand) {
424 8
            Html::addCssClass($options, ['collapsed']);
425
        }
426
427 9
        if ($tag === 'a') {
428 1
            $options['href'] = '#' . $collapseId;
429
        } else {
430 8
            $options['data-bs-target'] = '#' . $collapseId;
431
432 8
            if ($tag === 'button' && !isset($options['type'])) {
433 8
                $options['type'] = 'button';
434
            }
435
        }
436
437 9
        return Html::tag($tag, $label, $options)
438 9
            ->encode($encode)
439 9
            ->render();
440
    }
441
442
    /**
443
     * Render collapse item
444
     *
445
     * @param array $item
446
     * @param int $index
447
     *
448
     * @return string
449
     */
450 9
    private function renderCollapse(array $item, int $index): string
451
    {
452 9
        $expand = $item['expand'] ?? false;
453 9
        $options = $item['contentOptions'] ?? $this->contentOptions;
454 9
        $tag = ArrayHelper::remove($options, 'tag', 'div');
455 9
        $body = $this->renderBody($item);
456 8
        $options['id'] = $this->getCollapseId($item, $index);
457
458 8
        Html::addCssClass($options, ['accordion-collapse collapse']);
459
460 8
        if ($expand) {
461 7
            Html::addCssClass($options, ['show']);
462
        }
463
464 8
        if (!isset($options['aria-label'], $options['aria-labelledby'])) {
465 8
            $options['aria-labelledby'] = $this->getHeaderId($item, $index);
466
        }
467
468 8
        if ($this->autoCloseItems) {
469 8
            $options['data-bs-parent'] = '#' . $this->getId();
470
        }
471
472 8
        return Html::tag($tag, $body, $options)
473 8
            ->encode(false)
474 8
            ->render();
475
    }
476
477
    /**
478
     * Render collapse body
479
     *
480
     * @param array $item
481
     *
482
     * @return string
483
     */
484 9
    private function renderBody(array $item): string
485
    {
486 9
        $items = '';
487
488 9
        if ($this->isStringableObject($item['content'])) {
489 1
            $content = [$item['content']];
490
        } else {
491 9
            $content = (array) $item['content'];
492
        }
493
494 9
        foreach ($content as $value) {
495 9
            if (!is_string($value) && !is_numeric($value) && !$this->isStringableObject($value)) {
496 1
                throw new RuntimeException('The "content" option should be a string, array or object.');
497
            }
498
499 8
            $items .= $value;
500
        }
501
502 8
        return Html::div($items, ['class' => 'accordion-body'])
503 8
            ->encode($this->encodeTags)
504 8
            ->render();
505
    }
506
507
    /**
508
     * @param mixed $value
509
     *
510
     * @return bool
511
     */
512 9
    private function isStringableObject($value): bool
513
    {
514 9
        return is_object($value) && method_exists($value, '__toString');
515
    }
516
}
517