Passed
Push — master ( 2301b1...386ec7 )
by Alexander
14:22 queued 11:36
created

Tabs::render()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 0
dl 0
loc 9
ccs 6
cts 6
cp 1
crap 1
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
15
/**
16
 * Tabs renders a Tab bootstrap javascript component.
17
 *
18
 * For example:
19
 *
20
 * ```php
21
 * echo Tabs::widget()
22
 *     ->items([
23
 *         [
24
 *             'label' => 'One',
25
 *             'content' => 'Anim pariatur cliche...',
26
 *             'active' => true,
27
 *         ],
28
 *         [
29
 *             'label' => 'Two',
30
 *             'content' => 'Anim pariatur cliche...',
31
 *             'options' => [...],
32
 *             'paneOptions' => ['id' => 'myveryownID'],
33
 *         ],
34
 *         [
35
 *             'label' => 'Example',
36
 *             'url' => 'http://www.example.com',
37
 *         ],
38
 *         [
39
 *             'label' => 'Dropdown',
40
 *             'items' => [
41
 *                  [
42
 *                      'label' => 'DropdownA',
43
 *                      'content' => 'DropdownA, Anim pariatur cliche...',
44
 *                  ],
45
 *                  [
46
 *                      'label' => 'DropdownB',
47
 *                      'content' => 'DropdownB, Anim pariatur cliche...',
48
 *                  ],
49
 *                  [
50
 *                      'label' => 'External Link',
51
 *                      'url' => 'http://www.example.com',
52
 *                  ],
53
 *             ],
54
 *         ],
55
 *     ]);
56
 * ```
57
 *
58
 * @psalm-suppress MissingConstructor
59
 */
60
final class Tabs extends Widget
61
{
62
    public const NAV_PILLS = 'nav-pills';
63
64
    private array $items = [];
65
    private bool $encodeTags = false;
66
    private string $navType = 'nav-tabs';
67
    private bool $renderTabContent = true;
68
    private array $tabContentOptions = [];
69
    private array $paneOptions = [];
70
    private array $panes = [];
71
    private Nav $nav;
72
    private array $navDefinitions = [];
73
74 18
    public function getId(?string $suffix = '-tabs'): ?string
75
    {
76 18
        return $this->navDefinitions['options']['id'] ?? parent::getId($suffix);
77
    }
78
79 18
    public function render(): string
80
    {
81 18
        Html::addCssClass($this->tabContentOptions, ['tabContentOptions' => 'tab-content']);
82
83 18
        $navDefinitions = $this->prepareNavDefinitions();
84 18
        $this->prepareItems($this->items, $navDefinitions['options']['id']);
85 17
        $this->nav = $this->prepareNav($navDefinitions, $this->items);
86
87 17
        return $this->nav->render() . $this->renderPanes($this->panes);
88
    }
89
90
    /**
91
     * Set all options for nav widget
92
     */
93 8
    public function navDefinitions(array $definitions, bool $replace = true): self
94
    {
95 8
        $new = clone $this;
96
97 8
        if ($replace) {
98 1
            $new->navDefinitions = $definitions;
99
        } else {
100 7
            $new->navDefinitions = array_merge($new->navDefinitions, $definitions);
101
        }
102
103 8
        return $new;
104
    }
105
106
    /**
107
     * Set allowed option for Nav::widget
108
     */
109 7
    public function navDefinition(string $name, mixed $value): self
110
    {
111 7
        return $this->navDefinitions([$name => $value], false);
112
    }
113
114
    /**
115
     * Name of a class to use for rendering dropdowns withing this widget. Defaults to {@see Dropdown}.
116
     */
117 1
    public function dropdownClass(string $value): self
118
    {
119 1
        return $this->navDefinition('dropdownClass', $value);
120
    }
121
122
    /**
123
     * Base options for nav
124
     */
125
    public function dropdownOptions(array $options): self
126
    {
127
        return $this->navDefinition('dropdownOptions', $options);
128
    }
129
130
    /**
131
     * When tags Labels HTML should not be encoded.
132
     */
133 2
    public function withoutEncodeLabels(): self
134
    {
135 2
        return $this->navDefinition('withoutEncodeLabels', false);
136
    }
137
138
    /**
139
     * List of tabs in the tabs widget. Each array element represents a single tab with the following structure:
140
     *
141
     * - label: string, required, the tab header label.
142
     * - encode: bool, optional, whether this label should be HTML-encoded. This param will override
143
     *   global `$this->encodeLabels` param.
144
     * - headerOptions: array, optional, the HTML attributes of the tab header.
145
     * - content: string, optional, the content (HTML) of the tab pane.
146
     * - url: string, optional, an external URL. When this is specified, clicking on this tab will bring
147
     *   the browser to this URL.
148
     * - options: array, optional, the HTML attributes of the tab pane container.
149
     * - active: bool, optional, whether this item tab header and pane should be active. If no item is marked as
150
     *   'active' explicitly - the first one will be activated.
151
     * - visible: bool, optional, whether the item tab header and pane should be visible or not. Defaults to true.
152
     * - items: array, optional, can be used instead of `content` to specify a dropdown items
153
     *   configuration array. Each item can hold three extra keys, besides the above ones:
154
     *     * active: bool, optional, whether the item tab header and pane should be visible or not.
155
     *     * content: string, required if `items` is not set. The content (HTML) of the tab pane.
156
     *     * contentOptions: optional, array, the HTML attributes of the tab content container.
157
     *
158
     * @param array $value
159
     */
160 17
    public function items(array $value): self
161
    {
162 17
        $new = clone $this;
163 17
        $new->items = $value;
164
165 17
        return $new;
166
    }
167
168
    /**
169
     * List of HTML attributes for the header container tags. This will be overwritten by the "options" set in
170
     * individual {@see items}.
171
     *
172
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
173
     * {@see Nav::itemOptions()}
174
     *
175
     * @param array $value
176
     */
177 2
    public function itemOptions(array $value): self
178
    {
179 2
        return $this->navDefinition('itemOptions', $value);
180
    }
181
182
    /**
183
     * Options for each item link if not present in current item
184
     *
185
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
186
     * {@see Nav::linkOptions()}
187
     *
188
     * @param array $value
189
     */
190 1
    public function linkOptions(array $value): self
191
    {
192 1
        return $this->navDefinition('linkOptions', $value);
193
    }
194
195
    /**
196
     * List of HTML attributes for the item container tags. This will be overwritten by the "options" set in individual
197
     * {@see items}. The following special options are recognized.
198
     *
199
     * @param array $options
200
     *
201
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
202
     */
203 1
    public function paneOptions(array $options): self
204
    {
205 1
        $new = clone $this;
206 1
        $new->paneOptions = $options;
207
208 1
        return $new;
209
    }
210
211
    /**
212
     * Specifies the Bootstrap tab styling.
213
     */
214 4
    public function navType(string $value): self
215
    {
216 4
        $new = clone $this;
217 4
        $new->navType = $value;
218
219 4
        return $new;
220
    }
221
222
    /**
223
     * The HTML attributes for the widget container tag. The following special options are recognized.
224
     *
225
     * @param array $value
226
     *
227
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
228
     */
229 2
    public function options(array $value): self
230
    {
231 2
        return $this->navDefinition('options', $value);
232
    }
233
234
    /**
235
     * Tab panes (contents).
236
     */
237 1
    public function panes(array $value): self
238
    {
239 1
        $new = clone $this;
240 1
        $new->panes = $value;
241
242 1
        return $new;
243
    }
244
245
    /**
246
     * Manually render `tab-content` yourself in case your tab contents are complex.
247
     */
248 1
    public function withoutRenderTabContent(): self
249
    {
250 1
        $new = clone $this;
251 1
        $new->renderTabContent = false;
252
253 1
        return $new;
254
    }
255
256
    /**
257
     * List of HTML attributes for the `tab-content` container. This will always contain the CSS class `tab-content`.
258
     *
259
     * @param array $value
260
     *
261
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
262
     */
263 1
    public function tabContentOptions(array $value): self
264
    {
265 1
        $new = clone $this;
266 1
        $new->tabContentOptions = $value;
267
268 1
        return $new;
269
    }
270
271
    /**
272
     * Renders tab panes.
273
     *
274
     * @throws JsonException
275
     *
276
     * @return string the rendering result.
277
     */
278 17
    private function renderPanes(array $panes): string
279
    {
280 17
        return $this->renderTabContent
281 16
            ? ("\n" . Html::div(implode("\n", $panes), $this->tabContentOptions)->encode($this->encodeTags))
282 17
            : '';
283
    }
284
285
    /**
286
     * Prepare Nav::widget for using
287
     */
288 17
    private function prepareNav(array $definitions, array $items): Nav
289
    {
290 17
        $widgetDefinitions = [];
291
292 17
        foreach ($definitions as $name => $value) {
293 17
            $widgetDefinitions[$name . '()'] = [$value];
294
        }
295
296 17
        return Nav::widget([], $widgetDefinitions)->items($items);
0 ignored issues
show
Bug introduced by
The method items() does not exist on Yiisoft\Widget\Widget. It seems like you code against a sub-type of Yiisoft\Widget\Widget such as Yiisoft\Yii\Bootstrap5\Nav or Yiisoft\Yii\Bootstrap5\Carousel or Yiisoft\Yii\Bootstrap5\Dropdown or Yiisoft\Yii\Bootstrap5\Tabs or Yiisoft\Yii\Bootstrap5\Accordion. ( Ignorable by Annotation )

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

296
        return Nav::widget([], $widgetDefinitions)->/** @scrutinizer ignore-call */ items($items);
Loading history...
297
    }
298
299
    /**
300
     * Prepare options to send it to Nav::widget
301
     */
302 18
    private function prepareNavDefinitions(): array
303
    {
304 18
        $definitions = $this->navDefinitions;
305 18
        $definitions['options']['id'] = $this->getId();
306
307 18
        if (!isset($definitions['options']['role'])) {
308 18
            $definitions['options']['role'] = 'tablist';
309
        }
310
311
        /** @psalm-suppress InvalidArgument */
312 18
        Html::addCssClass($definitions['options'], ['widget' => 'nav', $this->navType]);
313
314 18
        return $definitions;
315
    }
316
317
    /**
318
     * Renders tab items as specified on {@see items}.
319
     *
320
     * @throws JsonException|RuntimeException
321
     */
322 18
    private function prepareItems(array &$items, ?string $navId, string $prefix = ''): void
323
    {
324 18
        if (!$this->hasActiveTab()) {
325 17
            $this->activateFirstVisibleTab();
326
        }
327
328 18
        foreach ($items as $n => $item) {
329 17
            $options = array_merge($this->paneOptions, ArrayHelper::remove($item, 'paneOptions', []));
330 17
            $options['id'] = ArrayHelper::getValue($options, 'id', $navId . $prefix . '-tab' . $n);
331
332
            /** {@see https://github.com/yiisoft/yii2-bootstrap4/issues/108#issuecomment-465219339} */
333 17
            unset($items[$n]['options']['id']);
334
335 17
            if (!ArrayHelper::remove($item, 'visible', true)) {
336 3
                continue;
337
            }
338
339 17
            if (!array_key_exists('label', $item)) {
340 1
                throw new RuntimeException('The "label" option is required.');
341
            }
342
343 16
            $selected = ArrayHelper::getValue($item, 'active', false);
344 16
            $disabled = ArrayHelper::getValue($item, 'disabled', false);
345
346 16
            if (isset($item['items'])) {
347 3
                $this->prepareItems($items[$n]['items'], $navId, '-dd' . $n);
348 3
                continue;
349
            }
350
351 16
            if (isset($item['url'])) {
352 3
                continue;
353
            }
354
355 16
            if (!isset($item['linkOptions'])) {
356 16
                $items[$n]['linkOptions'] = $this->navDefinitions['linkOptions'] ?? [];
357
            }
358
359 16
            ArrayHelper::setValue($items[$n], 'url', '#' . $options['id']);
360 16
            ArrayHelper::setValueByPath($items[$n], 'linkOptions.data.bs-toggle', 'tab');
361 16
            ArrayHelper::setValueByPath($items[$n], 'linkOptions.role', 'tab');
362 16
            ArrayHelper::setValueByPath($items[$n], 'linkOptions.aria-controls', $options['id']);
363
364 16
            if (!$disabled) {
365 16
                ArrayHelper::setValueByPath($items[$n], 'linkOptions.aria-selected', $selected ? 'true' : 'false');
366
            }
367
368
            /** @psalm-suppress InvalidArgument */
369 16
            Html::addCssClass($options, ['widget' => 'tab-pane']);
370
371 16
            if ($selected) {
372 16
                Html::addCssClass($options, ['active' => 'active']);
373
            }
374
375
            /** @psalm-suppress ConflictingReferenceConstraint */
376 16
            if ($this->renderTabContent) {
377 15
                $tag = ArrayHelper::remove($options, 'tag', 'div');
378 15
                $this->panes[] = Html::tag($tag, $item['content'] ?? '', $options)
379 15
                    ->encode($this->encodeTags)
380 15
                    ->render();
381
            }
382
        }
383
    }
384
385
    /**
386
     * @return bool if there's active tab defined.
387
     */
388 18
    private function hasActiveTab(): bool
389
    {
390 18
        foreach ($this->items as $item) {
391 17
            if (isset($item['active']) && $item['active'] === true) {
392 4
                return true;
393
            }
394
        }
395
396 17
        return false;
397
    }
398
399
    /**
400
     * Sets the first visible tab as active.
401
     *
402
     * This method activates the first tab that is visible and not explicitly set to inactive (`'active' => false`).
403
     */
404 17
    private function activateFirstVisibleTab(): void
405
    {
406 17
        foreach ($this->items as $i => $item) {
407 16
            $active = ArrayHelper::getValue($item, 'active', null);
408 16
            $visible = ArrayHelper::getValue($item, 'visible', true);
409 16
            $disabled = ArrayHelper::getValue($item, 'disabled', false);
410
411 16
            if ($visible && $active !== false && $disabled !== true) {
412 16
                $this->items[$i]['active'] = true;
413 16
                return;
414
            }
415
        }
416
    }
417
}
418