Passed
Pull Request — master (#79)
by Wilmer
02:24
created

Tabs::render()   A

Complexity

Conditions 5
Paths 16

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 18
nc 16
nop 0
dl 0
loc 32
ccs 19
cts 19
cp 1
crap 5
rs 9.3554
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Bulma;
6
7
use InvalidArgumentException;
8
use Yiisoft\Html\Html;
9
use Yiisoft\Html\Tag\A;
10
use Yiisoft\Html\Tag\Div;
11
use Yiisoft\Html\Tag\I;
12
use Yiisoft\Html\Tag\Span;
13
use Yiisoft\Widget\Widget;
14
15
use function array_key_exists;
16
use function array_reverse;
17
use function implode;
18
use function in_array;
19
use function is_bool;
20
21
/**
22
 * Simple responsive horizontal navigation tabs, with different styles.
23
 *
24
 * ```php
25
 * echo Tabs::widget()
26
 *     ->alignment(Tabs::ALIGNMENT_CENTERED)
27
 *     ->size(Tabs::SIZE_LARGE)
28
 *     ->style(Tabs::STYLE_BOX)
29
 *     ->items([
30
 *         [
31
 *             'label' => 'Pictures',
32
 *             'icon' => 'fas fa-image',
33
 *             'active' => true,
34
 *             'content' => 'Some text about pictures',
35
 *             'contentAttributes' => [
36
 *                 'class' => 'is-active',
37
 *             ],
38
 *         ],
39
 *         ['label' => 'Music', 'icon' => 'fas fa-music', 'content' => 'Some text about music'],
40
 *         ['label' => 'Videos', 'icon' => 'fas fa-film', 'content' => 'Some text about videos'],
41
 *         ['label' => 'Documents', 'icon' => 'far fa-file-alt', 'content' => 'Some text about documents'],
42
 *     ]);
43
 * ```
44
 *
45
 * @link https://bulma.io/documentation/components/tabs/
46
 */
47
final class Tabs extends Widget
48
{
49
    public const ALIGNMENT_CENTERED = 'is-centered';
50
    public const ALIGNMENT_RIGHT = 'is-right';
51
    private const ALIGNMENT_ALL = [
52
        self::ALIGNMENT_CENTERED,
53
        self::ALIGNMENT_RIGHT,
54
    ];
55
56
    public const SIZE_SMALL = 'is-small';
57
    public const SIZE_MEDIUM = 'is-medium';
58
    public const SIZE_LARGE = 'is-large';
59
    private const SIZE_ALL = [
60
        self::SIZE_SMALL,
61
        self::SIZE_MEDIUM,
62
        self::SIZE_LARGE,
63
    ];
64
65
    public const STYLE_BOX = 'is-boxed';
66
    public const STYLE_TOGGLE = 'is-toggle';
67
    public const STYLE_TOGGLE_ROUNDED = 'is-toggle is-toggle-rounded';
68
    public const STYLE_FULLWIDTH = 'is-fullwidth';
69
    private const STYLE_ALL = [
70
        self::STYLE_BOX,
71
        self::STYLE_TOGGLE,
72
        self::STYLE_TOGGLE_ROUNDED,
73
        self::STYLE_FULLWIDTH,
74
    ];
75
76
    private bool $activateItems = true;
77
    private string $alignment = '';
78
    private array $attributes = [];
79
    private string $autoIdPrefix = 'w';
80
    private ?string $currentPath = null;
81
    private bool $encode = true;
82
    private array $items = [];
83
    private array $itemsAttributes = [];
84
    private string $size = '';
85
    private string $style = '';
86
    private array $tabsContent = [];
87
    private array $tabsContentAttributes = [];
88
89
    /**
90
     * Returns a new instance with the specified alignment the tabs list.
91
     *
92
     * @param string $value The alignment the tabs list. By default, not class is added and the size is considered
93
     * "is-left". Possible values: Tabs::ALIGNMENT_CENTERED, Tabs::ALIGNMENT_RIGHT.
94
     *
95
     * @throws InvalidArgumentException
96
     *
97
     * @return self
98
     */
99 3
    public function alignment(string $value): self
100
    {
101 3
        if (!in_array($value, self::ALIGNMENT_ALL, true)) {
102 1
            $values = implode('", "', self::ALIGNMENT_ALL);
103 1
            throw new InvalidArgumentException("Invalid alignment. Valid values are: \"$values\".");
104
        }
105
106 2
        $new = clone $this;
107 2
        $new->alignment = $value;
108 2
        return $new;
109
    }
110
111
    /**
112
     * Returns a new instance with the specified HTML attributes for widget.
113
     *
114
     * @param array $values Attribute values indexed by attribute names.
115
     *
116
     * @return self
117
     *
118
     * {@see \Yiisoft\Html\Html::renderTagAttributes()} For details on how attributes are being rendered.
119
     */
120 2
    public function attributes(array $values): self
121
    {
122 2
        $new = clone $this;
123 2
        $new->attributes = $values;
124 2
        return $new;
125
    }
126
127
    /**
128
     * Returns a new instance with the specified prefix to the automatically generated widget IDs.
129
     *
130
     * @param string $value The prefix to the automatically generated widget IDs.
131
     *
132
     * @return self
133
     */
134 1
    public function autoIdPrefix(string $value): self
135
    {
136 1
        $new = clone $this;
137 1
        $new->autoIdPrefix = $value;
138 1
        return $new;
139
    }
140
141
    /**
142
     * Returns a new instance with the specified current path.
143
     *
144
     * @param string $value The current path.
145
     *
146
     * @return self
147
     */
148 3
    public function currentPath(string $value): self
149
    {
150 3
        $new = clone $this;
151 3
        $new->currentPath = $value;
152 3
        return $new;
153
    }
154
155
    /**
156
     * Returns a new instance with the specified disables active items according to their current path.
157
     *
158
     * @return self
159
     */
160 2
    public function deactivateItems(): self
161
    {
162 2
        $new = clone $this;
163 2
        $new->activateItems = false;
164 2
        return $new;
165
    }
166
167
    /**
168
     * Returns a new instance with the specified whether the tags for the tabs are encoded.
169
     *
170
     * @param bool $value Whether to encode the output.
171
     *
172
     * @return self
173
     */
174 2
    public function encode(bool $value): self
175
    {
176 2
        $new = clone $this;
177 2
        $new->encode = $value;
178 2
        return $new;
179
    }
180
181
    /**
182
     * Returns a new instance with the specified ID of the widget.
183
     *
184
     * @param string $value The ID of the widget.
185
     *
186
     * @return self
187
     */
188 2
    public function id(string $value): self
189
    {
190 2
        $new = clone $this;
191 2
        $new->attributes['id'] = $value;
192 2
        return $new;
193
    }
194
195
    /**
196
     * Returns a new instance with the specified items.
197
     *
198
     * @param array $value List of tabs items. Each tab item should be an array of the following structure:
199
     *
200
     * - `label`: string, required, the nav item label.
201
     * - `url`: string, optional, the item's URL.
202
     * - `visible`: bool, optional, whether this menu item is visible.
203
     * - `urlAttributes`: array, optional, the HTML attributes of the item's link.
204
     * - `attributes`: array, optional, the HTML attributes of the item container (LI).
205
     * - `active`: bool, optional, whether the item should be on active state or not.
206
     * - `encode`: bool, optional, whether the label will be HTML-encoded. If set, supersedes the $encode option
207
     *    for only this item.
208
     * - `icon`: string, the tab item icon.
209
     * - `iconAttributes`: array, optional, the HTML attributes of the item's icon.
210
     * - `rightSide`: bool, position the icon to the right.
211
     * - `content`: string, required if `items` is not set. The content (HTML) of the tab.
212
     * - `contentAttributes`: array, array, the HTML attributes of the tab content container.
213
     *
214
     * @return self
215
     */
216 11
    public function items(array $value): self
217
    {
218 11
        $new = clone $this;
219 11
        $new->items = $value;
220 11
        return $new;
221
    }
222
223
    /**
224
     * Returns a new instance with the specified attributes of the items.
225
     *
226
     * @param array $value List of HTML attributes for the items.
227
     *
228
     * {@see Html::renderTagAttributes()} For details on how attributes are being rendered.
229
     *
230
     * @return self
231
     */
232 1
    public function itemsAttributes(array $value): self
233
    {
234 1
        $new = clone $this;
235 1
        $new->itemsAttributes = $value;
236 1
        return $new;
237
    }
238
239
    /**
240
     * Returns a new instance with the specified size of the tabs list.
241
     *
242
     * @param string $value size class. By default, not class is added and the size is considered "normal".
243
     * Possible values: Tabs::SIZE_SMALL, Tabs::SIZE_MEDIUM, Tabs::SIZE_LARGE.
244
     *
245
     * {@see self::SIZE_ALL}
246
     *
247
     * @throws InvalidArgumentException
248
     *
249
     * @return self
250
     */
251 3
    public function size(string $value): self
252
    {
253 3
        if (!in_array($value, self::SIZE_ALL, true)) {
254 1
            $values = implode('", "', self::SIZE_ALL);
255 1
            throw new InvalidArgumentException("Invalid size. Valid values are: \"$values\".");
256
        }
257
258 2
        $new = clone $this;
259 2
        $new->size = $value;
260 2
        return $new;
261
    }
262
263
    /**
264
     * Returns a new instance with the specified style of the tabs list.
265
     *
266
     * @param string $value The style of the tabs list. By default, not class is added and the size is considered
267
     * "normal". Possible values: Tabs::STYLE_BOX, Tabs::STYLE_TOGGLE, Tabs::STYLE_TOGGLE_ROUNDED,
268
     * Tabs::STYLE_FULLWIDTH.
269
     *
270
     * @throws InvalidArgumentException
271
     *
272
     * @return self
273
     */
274 3
    public function style(string $value): self
275
    {
276 3
        if (!in_array($value, self::STYLE_ALL, true)) {
277 1
            $values = implode('", "', self::STYLE_ALL);
278 1
            throw new InvalidArgumentException("Invalid alignment. Valid values are: \"$values\".");
279
        }
280
281 2
        $new = clone $this;
282 2
        $new->style = $value;
283 2
        return $new;
284
    }
285
286
    /**
287
     * Returns a new instance with the specified attributes of the tabs content.
288
     *
289
     * @param array $value List of HTML attributes for the `tabs-content` container. This will always contain the CSS
290
     * class `tabs-content`.
291
     *
292
     * {@see Html::renderTagAttributes()} For details on how attributes are being rendered.
293
     *
294
     * @return self
295
     */
296 2
    public function tabsContentAttributes(array $value): self
297
    {
298 2
        $new = clone $this;
299 2
        $new->tabsContentAttributes = $value;
300 2
        return $new;
301
    }
302
303 15
    public function render(): string
304
    {
305 15
        $attributes = $this->attributes;
306
307 15
        $id = Html::generateId($this->autoIdPrefix) . '-tabs';
308
309 15
        if (array_key_exists('id', $attributes)) {
310
            /** @var string */
311 1
            $id = $attributes['id'];
312 1
            unset($attributes['id']);
313
        }
314
315 15
        Html::addCssClass($attributes, 'tabs');
316
317 15
        if ($this->size !== '') {
318 1
            Html::addCssClass($attributes, $this->size);
319
        }
320
321 15
        if ($this->alignment !== '') {
322 1
            Html::addCssClass($attributes, $this->alignment);
323
        }
324
325 15
        if ($this->style !== '') {
326 1
            Html::addCssClass($attributes, $this->style);
327
        }
328
329 15
        return Div::tag()
330 15
            ->attributes($attributes)
331 15
            ->content(PHP_EOL . $this->renderItems() . PHP_EOL)
332 15
            ->id($id)
333 15
            ->encode(false)
334 15
            ->render() . $this->renderTabsContent();
335
    }
336
337 15
    private function renderItems(): string
338
    {
339 15
        $items = $this->items;
340 15
        $renderItems = '';
341
342
        /**
343
         * @psalm-var array<
344
         *  int,
345
         *  array{
346
         *    active?: bool,
347
         *    content?: string,
348
         *    contentAttributes?: array,
349
         *    encode?: bool,
350
         *    icon?: string,
351
         *    items?: array,
352
         *    label: string,
353
         *    url: string,
354
         *    visible?: bool
355
         * }> $items
356
         */
357 15
        foreach ($items as $index => $item) {
358 10
            if (isset($item['visible']) && $item['visible'] === false) {
359 2
                continue;
360
            }
361
362 10
            $renderItems .= PHP_EOL . $this->renderItem($index, $item);
363
        }
364
365 14
        return Html::tag('ul', $renderItems . PHP_EOL, $this->itemsAttributes)
366 14
            ->encode(false)
367 14
            ->render();
368
    }
369
370
    /**
371
     * @param int $index
372
     * @param array $item
373
     *
374
     * @throws InvalidArgumentException
375
     *
376
     * @return string
377
     */
378 10
    private function renderItem(int $index, array $item): string
379
    {
380
        /** @var string */
381 10
        $url = $item['url'] ?? '';
382
383
        /** @var string */
384 10
        $icon = $item['icon'] ?? '';
385
386
        /** @var string */
387 10
        $label = $item['label'] ?? '';
388
389
        /** @var bool */
390 10
        $encode = $item['encode'] ?? $this->encode;
391
392
        /** @var array */
393 10
        $attributes = $item['attributes'] ?? [];
394
395
        /** @var array */
396 10
        $urlAttributes = $item['urlAttributes'] ?? [];
397
398
        /** @var array */
399 10
        $iconAttributes = $item['iconAttributes'] ?? [];
400
401
        /** @var string|null */
402 10
        $content = $item['content'] ?? null;
403
404
        /** @var array */
405 10
        $contentAttributes = $item['contentAttributes'] ?? [];
406 10
        $active = $this->isItemActive($item);
407
408 10
        if ($label === '') {
409 1
            throw new InvalidArgumentException("The 'label' option is required.");
410
        }
411
412 9
        if ($encode === true) {
413 9
            $label = Html::encode($label);
414
        }
415
416 9
        if ($icon !== '') {
417 2
            Html::addCssClass($iconAttributes, 'icon is-small');
418 2
            $label = $this->renderIcon($label, $icon, $iconAttributes);
419
        }
420
421 9
        if ($url !== '') {
422 4
            $urlAttributes['href'] = $url;
423
        }
424
425 9
        if ($active) {
426 4
            Html::addCssClass($attributes, ['active' => 'is-active']);
427
        }
428
429 9
        if ($content !== null) {
430 2
            if ($url === '') {
431 2
                $urlAttributes['href'] = '#' . Html::generateId('l') . '-tabs-c' . $index;
432
            }
433
434
            /** @var string */
435 2
            $contentAttributes['id'] ??= Html::generateId($this->autoIdPrefix) . '-tabs-c' . $index;
436
437 2
            $this->tabsContent[] = Div::tag()
438 2
                ->attributes($contentAttributes)
439 2
                ->content($content)
440 2
                ->encode(false)
441 2
                ->render();
442
        }
443
444 9
        return Html::tag(
445 9
            'li',
446 9
            A::tag()
447 9
                ->attributes($urlAttributes)
448 9
                ->content($label)
449 9
                ->encode(false)
450 9
                ->render(),
451 9
            $attributes
452 9
        )
453 9
            ->encode(false)
454 9
            ->render();
455
    }
456
457 2
    private function renderIcon(string $label, string $icon, array $iconAttributes): string
458
    {
459
        /** @var bool */
460 2
        $rightSide = $iconAttributes['rightSide'] ?? false;
461 2
        unset($iconAttributes['rightSide']);
462
463 2
        $elements = [
464 2
            Span::tag()
465 2
                ->attributes($iconAttributes)
466 2
                ->content(I::tag()
467 2
                    ->attributes(['class' => $icon, 'aria-hidden' => 'true'])
468 2
                    ->render())
469 2
                ->encode(false)
470 2
                ->render(),
471 2
            Span::tag()
472 2
                ->content($label)
473 2
                ->render(),
474 2
        ];
475
476 2
        if ($rightSide === true) {
477 1
            $elements = array_reverse($elements);
478
        }
479
480 2
        return implode('', $elements);
481
    }
482
483
    /**
484
     * Renders tabs content.
485
     *
486
     * @return string
487
     */
488 14
    private function renderTabsContent(): string
489
    {
490 14
        $html = '';
491
        /** @psalm-var string[] */
492 14
        $tabsContent = $this->tabsContent;
493 14
        $tabsContentAttributes = $this->tabsContentAttributes;
494
495 14
        Html::addCssClass($tabsContentAttributes, 'tabs-content');
496
497 14
        if (!empty($this->tabsContent)) {
498 2
            $html .= PHP_EOL . Div::tag()
499 2
                ->attributes($tabsContentAttributes)
500 2
                ->content(PHP_EOL . implode(PHP_EOL, $tabsContent) . PHP_EOL)
501 2
                ->encode(false)
502 2
                ->render();
503
        }
504
505 14
        return $html;
506
    }
507
508
    /**
509
     * @param array $item
510
     *
511
     * @return bool
512
     */
513 10
    private function isItemActive(array $item): bool
514
    {
515 10
        if (isset($item['active'])) {
516 3
            return is_bool($item['active']) && $item['active'];
517
        }
518
519 10
        return $this->activateItems && isset($item['url']) && $item['url'] === $this->currentPath;
520
    }
521
}
522