Passed
Pull Request — master (#47)
by Wilmer
02:42
created

Accordion::renderItems()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 17
c 1
b 0
f 0
nc 9
nop 0
dl 0
loc 30
ccs 18
cts 18
cp 1
crap 6
rs 9.0777
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_column;
13
use function array_key_exists;
14
use function array_merge;
15
use function array_search;
16
use function implode;
17
use function is_array;
18
use function is_numeric;
19
use function is_object;
20
use function is_string;
21
22
/**
23
 * Accordion renders an accordion bootstrap javascript component.
24
 *
25
 * For example:
26
 *
27
 * ```php
28
 * echo Accordion::widget()
29
 *     ->items([
30
 *         [
31
 *             'label' => 'Accordion Item #1',
32
 *             'content' => [
33
 *                 'This is the first items accordion body. It is shown by default, until the collapse plugin ' .
34
 *                 'the appropriate classes that we use to style each element. These classes control the ' .
35
 *                 'overall appearance, as well as the showing and hiding via CSS transitions. You can  ' .
36
 *                 'modify any of this with custom CSS or overriding our default variables. Its also worth ' .
37
 *                 'noting that just about any HTML can go within the .accordion-body, though the transition ' .
38
 *                 'does limit overflow.',
39
 *             ],
40
 *         ],
41
 *         [
42
 *             'label' => 'Accordion Item #2',
43
 *             'content' => '<strong>This is the second items accordion body.</strong> It is hidden by default, ' .
44
 *                 'until the collapse plugin adds the appropriate classes that we use to style each element. ' .
45
 *                 'These classes control the overall appearance, as well as the showing and hiding via CSS ' .
46
 *                 'transitions. You can modify any of this with custom CSS or overriding our default ' .
47
 *                 'variables. Its also worth noting that just about any HTML can go within the ' .
48
 *                 '<code>.accordion-body</code>, though the transition does limit overflow.',
49
 *             'contentOptions' => [
50
 *                 'class' => 'testContentOptions',
51
 *             ],
52
 *             'options' => [
53
 *                 'class' => 'testClass',
54
 *                 'id' => 'testId',
55
 *             ],
56
 *         ],
57
 *         [
58
 *             'label' => '<b>Accordion Item #3</b>',
59
 *             'content' => [
60
 *                 '<b>test content1</b>',
61
 *                 '<strong>This is the third items accordion body.</strong> It is hidden by default, until the ' .
62
 *                 'collapse plugin adds the appropriate classes that we use to style each element. These ' .
63
 *                 'classes control the overall appearance, as well as the showing and hiding via CSS ' .
64
 *                 'transitions. You can modify any of this with custom CSS or overriding our default ' .
65
 *                 'variables. Its also worth noting that just about any HTML can go within the ' .
66
 *                 '<code>.accordion-body</code>, though the transition does limit overflow.',
67
 *             ],
68
 *             'contentOptions' => [
69
 *                 'class' => 'testContentOptions2',
70
 *             ],
71
 *             'options' => [
72
 *                 'class' => 'testClass2',
73
 *                 'id' => 'testId2',
74
 *             ],
75
 *             'encode' => false,
76
 *         ],
77
 *     ]);
78
 * ```
79
 *
80
 * @link https://getbootstrap.com/docs/5.0/components/accordion/
81
 */
82
final class Accordion extends Widget
83
{
84
    private array $items = [];
85
    private bool $encodeLabels = true;
86
    private bool $encodeTags = false;
87
    private bool $autoCloseItems = true;
88
    private array $itemToggleOptions = [];
89
    private array $options = [];
90
    private bool $flush = false;
91
92 12
    protected function run(): string
93
    {
94 12
        if (!isset($this->options['id'])) {
95 12
            $this->options['id'] = "{$this->getId()}-accordion";
96
        }
97
98 12
        Html::addCssClass($this->options, ['widget' => 'accordion']);
99
100 12
        if ($this->flush) {
101 1
            Html::addCssClass($this->options, ['flush' => 'accordion-flush']);
102
        }
103
104 12
        return Html::div($this->renderItems(), $this->options)
105 7
            ->encode($this->encodeTags)
106 7
            ->render();
107
    }
108
109
    /**
110
     * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect.
111
     *
112
     * Set this to `false` to allow keeping multiple items open at once.
113
     *
114
     * @return self
115
     */
116 1
    public function allowMultipleOpenedItems(): self
117
    {
118 1
        $new = clone $this;
119 1
        $new->autoCloseItems = false;
120
121 1
        return $new;
122
    }
123
124
    /**
125
     * When tags Labels HTML should not be encoded.
126
     *
127
     * @return self
128
     */
129 1
    public function withoutEncodeLabels(): self
130
    {
131 1
        $new = clone $this;
132 1
        $new->encodeLabels = false;
133
134 1
        return $new;
135
    }
136
137
    /**
138
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
139
     *
140
     * - label: string, required, the group header label.
141
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
142
     *   `$this->encodeLabels` param.
143
     * - content: array|string|object, required, the content (HTML) of the group
144
     * - options: array, optional, the HTML attributes of the group
145
     * - contentOptions: optional, the HTML attributes of the group's content
146
     *
147
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
148
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
149
     * above.
150
     *
151
     * For example:
152
     *
153
     * ```php
154
     * echo Accordion::widget()
155
     *     ->items(
156
     *         [
157
     *             [
158
     *                 'Introduction' => 'This is the first collapsible menu',
159
     *                 'Second panel' => [
160
     *                     'content' => 'This is the second collapsible menu',
161
     *                 ],
162
     *             ],
163
     *             [
164
     *                 'label' => 'Third panel',
165
     *                 'content' => 'This is the third collapsible menu',
166
     *             ],
167
     *         ],
168
     *     );
169
     * ```
170
     *
171
     * @param array $value
172
     *
173
     * @return self
174
     */
175 12
    public function items(array $value): self
176
    {
177 12
        $new = clone $this;
178 12
        $new->items = $value;
179
180 12
        return $new;
181
    }
182
183
    /**
184
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
185
     *
186
     * For example:
187
     *
188
     * ```php
189
     * [
190
     *     'tag' => 'div',
191
     *     'class' => 'custom-toggle',
192
     * ]
193
     * ```
194
     *
195
     * @param array $value
196
     *
197
     * @return self
198
     */
199 1
    public function itemToggleOptions(array $value): self
200
    {
201 1
        $new = clone $this;
202 1
        $new->itemToggleOptions = $value;
203
204 1
        return $new;
205
    }
206
207
    /**
208
     * The HTML attributes for the widget container tag. The following special options are recognized.
209
     *
210
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
211
     *
212
     * @param array $value
213
     *
214
     * @return self
215
     */
216 1
    public function options(array $value): self
217
    {
218 1
        $new = clone $this;
219 1
        $new->options = $value;
220
221 1
        return $new;
222
    }
223
224
    /**
225
     * Add to remove the default background-color, some borders, and some rounded corners to render accordions
226
     * edge-to-edge with their parent container.
227
     *
228
     * @return self
229
     *
230
     * @link https://getbootstrap.com/docs/5.0/components/accordion/#flush
231
     */
232 1
    public function flush(): self
233
    {
234 1
        $new = clone $this;
235 1
        $new->flush = true;
236
237 1
        return $new;
238
    }
239
240
    /**
241
     * Renders collapsible items as specified on {@see items}.
242
     *
243
     * @throws JsonException|RuntimeException
244
     *
245
     * @return string the rendering result
246
     */
247 12
    private function renderItems(): string
248
    {
249 12
        $items = [];
250 12
        $index = 0;
251 12
        $expanded = array_search(true, array_column($this->items, 'expand'), true);
252
253 12
        foreach ($this->items as $key => $item) {
254 12
            if (!is_array($item)) {
255 1
                $item = ['content' => $item];
256
            }
257
258 12
            if ($expanded === false && $index === 0) {
259 11
                $item['expand'] = true;
260
            }
261
262 12
            if (!array_key_exists('label', $item)) {
263 3
                throw new RuntimeException('The "label" option is required.');
264
            }
265
266 9
            $header = ArrayHelper::remove($item, 'label');
267 9
            $options = ArrayHelper::getValue($item, 'options', []);
268
269 9
            Html::addCssClass($options, ['panel' => 'accordion-item']);
270
271 9
            $items[] = Html::div($this->renderItem($header, $item, $index++), $options)
0 ignored issues
show
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

271
            $items[] = Html::div($this->renderItem(/** @scrutinizer ignore-type */ $header, $item, $index++), $options)
Loading history...
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

271
            $items[] = Html::div($this->renderItem($header, /** @scrutinizer ignore-type */ $item, $index++), $options)
Loading history...
272 7
                ->encode($this->encodeTags)
273 7
                ->render();
274
        }
275
276 7
        return implode("\n", $items);
277
    }
278
279
    /**
280
     * Renders a single collapsible item group.
281
     *
282
     * @param string $header a label of the item group {@see items}
283
     * @param array $item a single item from {@see items}
284
     * @param int $index the item index as each item group content must have an id
285
     *
286
     * @throws JsonException|RuntimeException
287
     *
288
     * @return string the rendering result
289
     */
290 9
    private function renderItem(string $header, array $item, int $index): string
291
    {
292 9
        if (array_key_exists('content', $item)) {
293 8
            $id = $this->options['id'] . '-collapse' . $index;
294 8
            $expand = ArrayHelper::remove($item, 'expand', false);
295 8
            $options = ArrayHelper::getValue($item, 'contentOptions', []);
296 8
            $options['id'] = $id;
297
298 8
            Html::addCssClass($options, ['widget' => 'accordion-body collapse']);
299
300 8
            if ($expand) {
301 8
                Html::addCssClass($options, ['visibility' => 'show']);
302
            }
303
304 8
            if (!isset($options['aria-label'], $options['aria-labelledby'])) {
305 8
                $options['aria-labelledby'] = $options['id'] . '-heading';
306
            }
307
308 8
            $encodeLabel = $item['encode'] ?? $this->encodeLabels;
309
310 8
            if ($encodeLabel) {
311 8
                $header = Html::encode($header);
312
            }
313
314 8
            $itemToggleOptions = array_merge([
315 8
                'tag' => 'button',
316 8
                'type' => 'button',
317 8
                'data-bs-toggle' => 'collapse',
318 8
                'data-bs-target' => '#' . $options['id'],
319 8
                'aria-expanded' => $expand ? 'true' : 'false',
320 8
                'aria-controls' => $options['id'],
321 8
            ], $this->itemToggleOptions);
322
323 8
            $itemToggleTag = ArrayHelper::remove($itemToggleOptions, 'tag', 'button');
324
325 8
            if ($itemToggleTag === 'a') {
326 1
                ArrayHelper::remove($itemToggleOptions, 'data-bs-target');
327 1
                $header = Html::a($header, '#' . $id, $itemToggleOptions)->encode($this->encodeTags) . "\n";
328
            } else {
329 7
                Html::addCssClass($itemToggleOptions, ['widget' => 'accordion-button']);
330 7
                if (!$expand) {
331 6
                    Html::addCssClass($itemToggleOptions, ['expand' => 'collapsed']);
332
                }
333 7
                $header = Html::button($header, $itemToggleOptions)->encode($this->encodeTags)->render();
334
            }
335
336 8
            if (is_string($item['content']) || is_numeric($item['content']) || is_object($item['content'])) {
337 7
                $content = $item['content'];
338 3
            } elseif (is_array($item['content'])) {
339 2
                $items = [];
340 2
                foreach ($item['content'] as $content) {
341 2
                    $items[] = Html::div($content)
342 2
                        ->attributes(['class' => 'accordion-body'])
343 2
                        ->encode($this->encodeTags)
344 2
                        ->render();
345
                }
346
347 2
                $content = implode("\n", $items);
348
            } else {
349 8
                throw new RuntimeException('The "content" option should be a string, array or object.');
350
            }
351
        } else {
352 1
            throw new RuntimeException('The "content" option is required.');
353
        }
354
355 7
        $group = [];
356
357 7
        if ($this->autoCloseItems) {
358 7
            $options['data-bs-parent'] = '#' . $this->options['id'];
359
        }
360
361 7
        $groupOptions = ['class' => 'accordion-header', 'id' => $options['id'] . '-heading'];
362
363 7
        $group[] = Html::tag('h2', $header, $groupOptions)->encode($this->encodeTags);
364 7
        $group[] = Html::div($content, $options)->encode($this->encodeTags);
365
366 7
        return implode("\n", $group);
367
    }
368
}
369