Passed
Pull Request — master (#85)
by Albert
13:00 queued 10:33
created

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