Passed
Push — master ( b3c21f...007d67 )
by Alexander
12:51
created

Accordion::headerOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 6
ccs 0
cts 4
cp 0
crap 2
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_object;
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 13
    public function beforeRun(): bool
100
    {
101 13
        Html::addCssClass($this->options, ['widget' => 'accordion']);
102
103 13
        if ($this->flush) {
104 1
            Html::addCssClass($this->options, ['flush' => 'accordion-flush']);
105
        }
106
107 13
        if (!isset($this->options['id'])) {
108 13
            $this->options['id'] = $this->getId();
109
        }
110
111 13
        return parent::beforeRun();
112
    }
113
114 13
    protected function run(): string
115
    {
116 13
        return Html::div($this->renderItems(), $this->options)
117 8
            ->encode($this->encodeTags)
118 8
            ->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
     * @return self
127
     */
128 1
    public function allowMultipleOpenedItems(): self
129
    {
130 1
        $new = clone $this;
131 1
        $new->autoCloseItems = false;
132
133 1
        return $new;
134
    }
135
136
    /**
137
     * When tags Labels HTML should not be encoded.
138
     *
139
     * @return self
140
     */
141 1
    public function withoutEncodeLabels(): self
142
    {
143 1
        $new = clone $this;
144 1
        $new->encodeLabels = false;
145
146 1
        return $new;
147
    }
148
149
    /**
150
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
151
     *
152
     * - label: string, required, the group header label.
153
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
154
     *   `$this->encodeLabels` param.
155
     * - content: array|string|object, required, the content (HTML) of the group
156
     * - options: array, optional, the HTML attributes of the group
157
     * - contentOptions: optional, the HTML attributes of the group's content
158
     *
159
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
160
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
161
     * above.
162
     *
163
     * For example:
164
     *
165
     * ```php
166
     * echo Accordion::widget()
167
     *     ->items(
168
     *         [
169
     *             [
170
     *                 'Introduction' => 'This is the first collapsible menu',
171
     *                 'Second panel' => [
172
     *                     'content' => 'This is the second collapsible menu',
173
     *                 ],
174
     *             ],
175
     *             [
176
     *                 'label' => 'Third panel',
177
     *                 'content' => 'This is the third collapsible menu',
178
     *             ],
179
     *         ],
180
     *     );
181
     * ```
182
     *
183
     * @param array $value
184
     *
185
     * @return self
186
     */
187 13
    public function items(array $value): self
188
    {
189 13
        $new = clone $this;
190 13
        $new->items = $value;
191 13
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $this->defaultExpand, $new->items);
192
193 13
        return $new;
194
    }
195
196
    /**
197
     * Set expand property for items without it
198
     *
199
     * @param bool|null $default
200
     *
201
     * @return self
202
     */
203 1
    public function defaultExpand(?bool $default): self
204
    {
205 1
        if ($default === $this->defaultExpand) {
206
            return $this;
207
        }
208
209 1
        $new = clone $this;
210 1
        $new->defaultExpand = $default;
211 1
        $new->expands = array_map(fn ($item) => isset($item['expand']) ? (bool) $item['expand'] : $new->defaultExpand, $new->items);
212
213 1
        return $new;
214
    }
215
216
    /**
217
     * Options for each header if not present in item
218
     *
219
     * @param array $options
220
     *
221
     * @return self
222
     */
223
    public function headerOptions(array $options): self
224
    {
225
        $new = clone $this;
226
        $new->headerOptions = $options;
227
228
        return $new;
229
    }
230
231
    /**
232
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
233
     *
234
     * For example:
235
     *
236
     * ```php
237
     * [
238
     *     'tag' => 'div',
239
     *     'class' => 'custom-toggle',
240
     * ]
241
     * ```
242
     *
243
     * @param array $value
244
     *
245
     * @return self
246
     */
247 1
    public function itemToggleOptions(array $value): self
248
    {
249 1
        $new = clone $this;
250 1
        $new->itemToggleOptions = $value;
251
252 1
        return $new;
253
    }
254
255
    /**
256
     * Content options for items if not present in current
257
     *
258
     * @param array $options
259
     *
260
     * @return self
261
     */
262
    public function contentOptions(array $options): self
263
    {
264
        $new = clone $this;
265
        $new->contentOptions = $options;
266
267
        return $new;
268
    }
269
270
    /**
271
     * The HTML attributes for the widget container tag. The following special options are recognized.
272
     *
273
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
274
     *
275
     * @param array $value
276
     *
277
     * @return self
278
     */
279 1
    public function options(array $value): self
280
    {
281 1
        $new = clone $this;
282 1
        $new->options = $value;
283
284 1
        return $new;
285
    }
286
287
    /**
288
     * Remove the default background-color, some borders, and some rounded corners to render accordions
289
     * edge-to-edge with their parent container.
290
     *
291
     * @return self
292
     *
293
     * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush
294
     */
295 1
    public function flush(): self
296
    {
297 1
        $new = clone $this;
298 1
        $new->flush = true;
299
300 1
        return $new;
301
    }
302
303
    /**
304
     * Renders collapsible items as specified on {@see items}.
305
     *
306
     * @throws JsonException|RuntimeException
307
     *
308
     * @return string the rendering result
309
     */
310 13
    private function renderItems(): string
311
    {
312 13
        $items = [];
313 13
        $index = 0;
314 13
        $expanded = in_array(true, $this->expands, true);
315 13
        $allClose = !$expanded && count($this->items) === count(array_filter($this->expands, fn ($expand) => $expand === false));
316
317 13
        foreach ($this->items as $item) {
318 13
            if (!is_array($item)) {
319 1
                $item = ['content' => $item];
320
            }
321
322 13
            if ($allClose === false && $expanded === false && $index === 0) {
323 11
                $item['expand'] = true;
324
            }
325
326 13
            if (!array_key_exists('label', $item)) {
327 3
                throw new RuntimeException('The "label" option is required.');
328
            }
329
330 10
            $header = ArrayHelper::remove($item, 'label');
331 10
            $options = ArrayHelper::getValue($item, 'options', []);
332
333 10
            Html::addCssClass($options, ['panel' => 'accordion-item']);
334
335 10
            $items[] = Html::div($this->renderItem($header, $item, $index++), $options)
0 ignored issues
show
Bug introduced by
It seems like $item can also be of type object; however, parameter $item of Yiisoft\Yii\Bootstrap5\Accordion::renderItem() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

335
            $items[] = Html::div($this->renderItem($header, /** @scrutinizer ignore-type */ $item, $index++), $options)
Loading history...
Bug introduced by
It seems like $header can also be of type null; however, parameter $header of Yiisoft\Yii\Bootstrap5\Accordion::renderItem() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

335
            $items[] = Html::div($this->renderItem(/** @scrutinizer ignore-type */ $header, $item, $index++), $options)
Loading history...
336 8
                ->encode($this->encodeTags)
337 8
                ->render();
338
        }
339
340 8
        return implode("\n", $items);
341
    }
342
343
    /**
344
     * Renders a single collapsible item group.
345
     *
346
     * @param string $header a label of the item group {@see items}
347
     * @param array $item a single item from {@see items}
348
     * @param int $index the item index as each item group content must have an id
349
     *
350
     * @throws JsonException|RuntimeException
351
     *
352
     * @return string the rendering result
353
     */
354 10
    private function renderItem(string $header, array $item, int $index): string
355
    {
356 10
        if (array_key_exists('content', $item)) {
357 9
            $expand = ArrayHelper::remove($item, 'expand', false);
358 9
            $options = ArrayHelper::getValue($item, 'contentOptions', $this->contentOptions);
359 9
            $headerOptions = ArrayHelper::remove($item, 'headerOptions', $this->headerOptions);
0 ignored issues
show
Bug introduced by
It seems like $item can also be of type object; however, parameter $array of Yiisoft\Arrays\ArrayHelper::remove() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

359
            $headerOptions = ArrayHelper::remove(/** @scrutinizer ignore-type */ $item, 'headerOptions', $this->headerOptions);
Loading history...
360
361 9
            if (!isset($options['id'])) {
362 9
                $options['id'] = $this->getId() . '-collapse' . $index;
363
            }
364
365 9
            if (!isset($headerOptions['id'])) {
366 9
                $headerOptions['id'] = $options['id'] . '-heading';
367
            }
368
369 9
            Html::addCssClass($headerOptions, ['widget' => 'accordion-header']);
370 9
            Html::addCssClass($options, ['widget' => 'accordion-collapse collapse']);
371
372 9
            if ($expand) {
373 8
                Html::addCssClass($options, ['visibility' => 'show']);
374
            }
375
376 9
            if (!isset($options['aria-label'], $options['aria-labelledby'])) {
377 9
                $options['aria-labelledby'] = $headerOptions['id'];
378
            }
379
380 9
            $encodeLabel = $item['encode'] ?? $this->encodeLabels;
381
382 9
            if ($encodeLabel) {
383 9
                $header = Html::encode($header);
384
            }
385
386 9
            $itemToggleOptions = array_merge([
387 9
                'tag' => 'button',
388 9
                'type' => 'button',
389 9
                'data-bs-toggle' => 'collapse',
390 9
                'data-bs-target' => '#' . $options['id'],
391 9
                'aria-expanded' => $expand ? 'true' : 'false',
392 9
                'aria-controls' => $options['id'],
393 9
            ], ArrayHelper::remove($item, 'toggleOptions', $this->itemToggleOptions));
394
395 9
            $itemToggleTag = ArrayHelper::remove($itemToggleOptions, 'tag', 'button');
396
397 9
            if ($itemToggleTag === 'a') {
398 1
                ArrayHelper::remove($itemToggleOptions, 'data-bs-target');
399 1
                $header = Html::a($header, '#' . $options['id'], $itemToggleOptions)->encode($this->encodeTags) . "\n";
400
            } else {
401 8
                Html::addCssClass($itemToggleOptions, ['widget' => 'accordion-button']);
402 8
                if (!$expand) {
403 7
                    Html::addCssClass($itemToggleOptions, ['expand' => 'collapsed']);
404
                }
405 8
                $header = Html::button($header, $itemToggleOptions)->encode($this->encodeTags)->render();
406
            }
407
408 9
            if (is_string($item['content']) || is_numeric($item['content']) || is_object($item['content'])) {
409 8
                $content = $item['content'];
410 3
            } elseif (is_array($item['content'])) {
411 2
                $items = [];
412 2
                foreach ($item['content'] as $content) {
413 2
                    $items[] = Html::div($content)
414 2
                        ->attributes(['class' => 'accordion-body'])
415 2
                        ->encode($this->encodeTags)
416 2
                        ->render();
417
                }
418
419 2
                $content = implode("\n", $items);
420
            } else {
421 9
                throw new RuntimeException('The "content" option should be a string, array or object.');
422
            }
423
        } else {
424 1
            throw new RuntimeException('The "content" option is required.');
425
        }
426
427 8
        if ($this->autoCloseItems) {
428 8
            $options['data-bs-parent'] = '#' . $this->getId();
429
        }
430
431 8
        $headerTag = ArrayHelper::remove($headerOptions, 'tag', 'h2');
432 8
        $group = Html::tag($headerTag, $header, $headerOptions)->encode($this->encodeTags) . "\n";
433 8
        $group .= Html::div($content, $options)->encode($this->encodeTags);
434
435 8
        return $group;
436
    }
437
}
438