Passed
Pull Request — master (#35)
by Wilmer
02:31
created

Accordion::withOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 6
ccs 4
cts 4
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 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
 *     ->witItems([
30
 *         [
31
 *             'label' => 'Collapsible Group Item #1',
32
 *             'content' => 'Anim pariatur cliche...',
33
 *             // open its content by default
34
 *             'contentOptions' => ['class' => 'show'],
35
 *         ],
36
 *         // another group item
37
 *         [
38
 *             'label' => 'Collapsible Group Item #2',
39
 *             'content' => 'Anim pariatur cliche...',
40
 *             'contentOptions' => [...],
41
 *             'options' => [...],
42
 *             'expand' => true,
43
 *         ],
44
 *         // if you want to swap out .accordion-body with .list-group, you may provide an array
45
 *         [
46
 *             'label' => 'Collapsible Group Item #3',
47
 *             'content' => [
48
 *                 'Anim pariatur cliche...',
49
 *                 'Anim pariatur cliche...',
50
 *             ],
51
 *             'contentOptions' => [...],
52
 *             'options' => [...],
53
 *         ],
54
 *     ]);
55
 * ```
56
 */
57
final class Accordion extends Widget
58
{
59
    private array $items = [];
60
    private bool $encodeLabels = true;
61
    private bool $encodeTags = false;
62
    private bool $autoCloseItems = true;
63
    private array $itemToggleOptions = [];
64
    private array $options = [];
65
66 11
    protected function run(): string
67
    {
68 11
        if (!isset($this->options['id'])) {
69 11
            $this->options['id'] = "{$this->getId()}-accordion";
70
        }
71
72
        /** @psalm-suppress InvalidArgument */
73 11
        Html::addCssClass($this->options, ['widget' => 'accordion']);
74
75 11
        if ($this->encodeTags === false) {
76 10
            $this->options = array_merge($this->options, ['encode' => false]);
77
        }
78
79 11
        return Html::div($this->renderItems(), $this->options);
80
    }
81
82
    /**
83
     * Whether to close other items if an item is opened. Defaults to `true` which causes an accordion effect.
84
     *
85
     * Set this to `false` to allow keeping multiple items open at once.
86
     *
87
     * @param bool $value
88
     *
89
     * @return $this
90
     */
91 1
    public function withoutAutoCloseItems(bool $value = false): self
92
    {
93 1
        $new = clone $this;
94 1
        $new->autoCloseItems = $value;
95
96 1
        return $new;
97
    }
98
99
    /**
100
     * Whether the labels for header items should be HTML-encoded.
101
     *
102
     * @param bool $value
103
     *
104
     * @return $this
105
     */
106 1
    public function withoutEncodeLabels(bool $value = false): self
107
    {
108 1
        $new = clone $this;
109 1
        $new->encodeLabels = $value;
110
111 1
        return $new;
112
    }
113
114
    /**
115
     * List of groups in the collapse widget. Each array element represents a single group with the following structure:
116
     *
117
     * - label: string, required, the group header label.
118
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override global
119
     *   `$this->encodeLabels` param.
120
     * - content: array|string|object, required, the content (HTML) of the group
121
     * - options: array, optional, the HTML attributes of the group
122
     * - contentOptions: optional, the HTML attributes of the group's content
123
     *
124
     * You may also specify this property as key-value pairs, where the key refers to the `label` and the value refers
125
     * to `content`. If value is a string it is interpreted as label. If it is an array, it is interpreted as explained
126
     * above.
127
     *
128
     * For example:
129
     *
130
     * ```php
131
     * echo Accordion::widget([
132
     *     'withItems' => [
133
     *       'Introduction' => 'This is the first collapsible menu',
134
     *       'Second panel' => [
135
     *           'content' => 'This is the second collapsible menu',
136
     *       ],
137
     *       [
138
     *           'label' => 'Third panel',
139
     *           'content' => 'This is the third collapsible menu',
140
     *       ],
141
     *   ]
142
     * ])
143
     * ```
144
     *
145
     * @param array $value
146
     *
147
     * @return $this
148
     */
149 11
    public function withItems(array $value): self
150
    {
151 11
        $new = clone $this;
152 11
        $new->items = $value;
153
154 11
        return $new;
155
    }
156
157
    /**
158
     * The HTML options for the item toggle tag. Key 'tag' might be used here for the tag name specification.
159
     *
160
     * For example:
161
     *
162
     * ```php
163
     * [
164
     *     'tag' => 'div',
165
     *     'class' => 'custom-toggle',
166
     * ]
167
     * ```
168
     *
169
     * @param array $value
170
     *
171
     * @return $this
172
     */
173 1
    public function withItemToggleOptions(array $value): self
174
    {
175 1
        $new = clone $this;
176 1
        $new->itemToggleOptions = $value;
177
178 1
        return $new;
179
    }
180
181
    /**
182
     * The HTML attributes for the widget container tag. The following special options are recognized.
183
     *
184
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
185
     *
186
     * @param array $value
187
     *
188
     * @return $this
189
     */
190 1
    public function withOptions(array $value): self
191
    {
192 1
        $new = clone $this;
193 1
        $new->options = $value;
194
195 1
        return $new;
196
    }
197
198
    /**
199
     * Allows you to enable or disable the encoding tags html.
200
     *
201
     * @param bool $value
202
     *
203
     * @return self
204
     */
205 1
    public function withEncodeTags(bool $value = true): self
206
    {
207 1
        $new = clone $this;
208 1
        $new->encodeTags = $value;
209
210 1
        return $new;
211
    }
212
213
    /**
214
     * Renders collapsible items as specified on {@see items}.
215
     *
216
     * @throws JsonException|RuntimeException
217
     *
218
     * @return string the rendering result
219
     */
220 11
    private function renderItems(): string
221
    {
222 11
        $items = [];
223 11
        $index = 0;
224 11
        $expanded = array_search(true, array_column($this->items, 'expand'), true);
225
226 11
        foreach ($this->items as $key => $item) {
227 11
            if (!is_array($item)) {
228 1
                $item = ['content' => $item];
229
            }
230
231 11
            if ($expanded === false && $index === 0) {
232 10
                $item['expand'] = true;
233
            }
234
235 11
            if (!array_key_exists('label', $item)) {
236 3
                throw new RuntimeException('The "label" option is required.');
237
            }
238
239 8
            $header = ArrayHelper::remove($item, 'label');
240 8
            $options = ArrayHelper::getValue($item, 'options', []);
241
242 8
            if ($this->encodeTags === false) {
243 7
                ArrayHelper::setValue($options, 'encode', false);
244
            }
245
246 8
            Html::addCssClass($options, ['panel' => 'accordion-item']);
247
248 8
            $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

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

248
            $items[] = Html::div($this->renderItem($header, /** @scrutinizer ignore-type */ $item, $index++), $options);
Loading history...
249
        }
250
251 7
        return implode("\n", $items);
252
    }
253
254
    /**
255
     * Renders a single collapsible item group.
256
     *
257
     * @param string $header a label of the item group {@see items}
258
     * @param array $item a single item from {@see items}
259
     * @param int $index the item index as each item group content must have an id
260
     *
261
     * @throws JsonException|RuntimeException
262
     *
263
     * @return string the rendering result
264
     */
265 8
    private function renderItem(string $header, array $item, int $index): string
266
    {
267 8
        if (array_key_exists('content', $item)) {
268 7
            $id = $this->options['id'] . '-collapse' . $index;
269 7
            $expand = ArrayHelper::remove($item, 'expand', false);
270 7
            $options = ArrayHelper::getValue($item, 'contentOptions', []);
271 7
            $options['id'] = $id;
272
273 7
            Html::addCssClass($options, ['widget' => 'accordion-body collapse']);
274
275 7
            if ($expand) {
276 7
                Html::addCssClass($options, ['visibility' => 'show']);
277
            }
278
279 7
            if (!isset($options['aria-label'], $options['aria-labelledby'])) {
280 7
                $options['aria-labelledby'] = $options['id'] . '-heading';
281
            }
282
283 7
            $encodeLabel = $item['encode'] ?? $this->encodeLabels;
284
285 7
            if ($encodeLabel) {
286 7
                $header = Html::encode($header);
287
            }
288
289 7
            $itemToggleOptions = array_merge([
290 7
                'tag' => 'button',
291 7
                'type' => 'button',
292 7
                'data-bs-toggle' => 'collapse',
293 7
                'data-bs-target' => '#' . $options['id'],
294 7
                'aria-expanded' => $expand ? 'true' : 'false',
295 7
            ], $this->itemToggleOptions);
296
297 7
            if ($this->encodeTags === false) {
298 6
                $itemToggleOptions = array_merge($itemToggleOptions, ['encode' => false]);
299
            }
300
301 7
            $itemToggleTag = ArrayHelper::remove($itemToggleOptions, 'tag', 'button');
302
303
            /** @psalm-suppress ConflictingReferenceConstraint */
304 7
            if ($itemToggleTag === 'a') {
305 1
                ArrayHelper::remove($itemToggleOptions, 'data-bs-target');
306 1
                $header = Html::a($header, '#' . $id, $itemToggleOptions) . "\n";
307
            } else {
308 6
                Html::addCssClass($itemToggleOptions, ['widget' => 'accordion-button']);
309 6
                if (!$expand) {
310 6
                    Html::addCssClass($itemToggleOptions, ['expand' => 'collapsed']);
311
                }
312 6
                $header = Html::button($header, $itemToggleOptions);
313
            }
314
315 7
            if (is_string($item['content']) || is_numeric($item['content']) || is_object($item['content'])) {
316 7
                $content = $item['content'];
317 1
            } elseif (is_array($item['content'])) {
318 1
                $ulOptions = ['class' => 'list-group'];
319 1
                $ulItemOptions = ['itemOptions' => ['class' => 'list-group-item']];
320
321 1
                if ($this->encodeTags === false) {
322 1
                    $ulOptions = array_merge($ulOptions, ['encode' => false]);
323 1
                    $ulItemOptions['itemOptions'] = array_merge($ulItemOptions['itemOptions'], ['encode' => false]);
324
                }
325
326 1
                $content = Html::ul($item['content'], array_merge($ulOptions, $ulItemOptions)) . "\n";
327
            } else {
328 7
                throw new RuntimeException('The "content" option should be a string, array or object.');
329
            }
330
        } else {
331 1
            throw new RuntimeException('The "content" option is required.');
332
        }
333
334 7
        $group = [];
335
336 7
        if ($this->autoCloseItems) {
337 7
            $options['data-bs-parent'] = '#' . $this->options['id'];
338
        }
339
340 7
        $groupOptions = ['class' => 'accordion-header', 'id' => $options['id'] . '-heading'];
341
342 7
        if ($this->encodeTags === false) {
343 6
            $options = array_merge($options, ['encode' => false]);
344 6
            $groupOptions = array_merge($groupOptions, ['encode' => false]);
345
        }
346
347 7
        $group[] = Html::tag('h2', $header, $groupOptions);
348 7
        $group[] = Html::div($content, $options);
349
350 7
        return implode("\n", $group);
351
    }
352
}
353