Passed
Pull Request — master (#72)
by
unknown
02:35
created

Accordion::beforeRun()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

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

314
            $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

314
            $items[] = Html::div($this->renderItem(/** @scrutinizer ignore-type */ $header, $item, $index++), $options)
Loading history...
315 8
                ->encode($this->encodeTags)
316 8
                ->render();
317
        }
318
319 8
        return implode("\n", $items);
320
    }
321
322
    /**
323
     * Renders a single collapsible item group.
324
     *
325
     * @param string $header a label of the item group {@see items}
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 10
    private function renderItem(string $header, array $item, int $index): string
334
    {
335 10
        if (array_key_exists('content', $item)) {
336 9
            $expand = ArrayHelper::remove($item, 'expand', false);
337 9
            $options = ArrayHelper::getValue($item, 'contentOptions', $this->contentOptions);
338 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

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