Passed
Push — master ( 438f38...27fec6 )
by Wilmer
02:48
created

Dropdown::prepareDropdownLayout()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 0
dl 0
loc 13
ccs 8
cts 8
cp 1
crap 2
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 Stringable;
10
use Yiisoft\Arrays\ArrayHelper;
11
use Yiisoft\Definitions\Exception\InvalidConfigException;
12
use Yiisoft\Html\Html;
13
use Yiisoft\Html\Tag\CustomTag;
14
use Yiisoft\Html\Tag\Li;
15
use Yiisoft\Html\Tag\Ul;
16
use function array_key_exists;
17
use function array_merge;
18
use function array_merge_recursive;
19
use function array_unique;
20
use function is_array;
21
use function is_string;
22
23
/**
24
 * Dropdown renders a Bootstrap dropdown menu component.
25
 *
26
 * For example,
27
 *
28
 * ```php
29
 * <div class="dropdown">
30
 *     <?php
31
 *         echo Dropdown::widget()
32
 *             ->items([
33
 *                 ['label' => 'DropdownA', 'url' => '/'],
34
 *                 ['label' => 'DropdownB', 'url' => '#'],
35
 *             ]);
36
 *     ?>
37
 * </div>
38
 * ```
39
 */
40
final class Dropdown extends Widget
41
{
42
    public const ALIGNMENT_END = 'dropdown-menu-end';
43
    public const ALIGNMENT_SM_END = 'dropdown-menu-sm-end';
44
    public const ALIGNMENT_MD_END = 'dropdown-menu-md-end';
45
    public const ALIGNMENT_LG_END = 'dropdown-menu-lg-end';
46
    public const ALIGNMENT_XL_END = 'dropdown-menu-xl-end';
47
    public const ALIGNMENT_XXL_END = 'dropdown-menu-xxl-end';
48
    public const ALIGNMENT_SM_START = 'dropdown-menu-sm-start';
49
    public const ALIGNMENT_MD_START = 'dropdown-menu-md-start';
50
    public const ALIGNMENT_LG_START = 'dropdown-menu-lg-start';
51
    public const ALIGNMENT_XL_START = 'dropdown-menu-xl-start';
52
    public const ALIGNMENT_XXL_START = 'dropdown-menu-xxl-start';
53
54
    private array|string|Stringable|null $items = [];
55
    private bool $encodeLabels = true;
56
    private bool $encodeTags = false;
57
    private array $submenuOptions = [];
58
    private array $options = [];
59
    private array $itemOptions = [];
60
    private array $linkOptions = [];
61
    private array $alignment = [];
62
63
    private ?string $tagName = null;
64
65 40
    public function getId(?string $suffix = '-dropdown'): ?string
66
    {
67 40
        return $this->options['id'] ?? parent::getId($suffix);
68
    }
69
70 40
    private function prepareOptions(): array
71
    {
72 40
        $options = $this->options;
73 40
        $options['id'] = $this->getId();
74 40
        $classNames = array_merge(['widget' => 'dropdown-menu'], $this->alignment);
75
76 40
        if ($this->theme) {
77 2
            $options['data-bs-theme'] = $this->theme;
78
79 2
            if ($this->theme === self::THEME_DARK) {
80 2
                $classNames[] = 'dropdown-menu-dark';
81
            }
82
        }
83
84
        /** @psalm-suppress InvalidArgument */
85 40
        Html::addCssClass($options, $classNames);
86
87 40
        return $options;
88
    }
89
90 39
    private function prepareDropdownLayout(): CustomTag|Ul
91
    {
92 39
        $options = $this->prepareOptions();
93
94 39
        if (is_array($this->items)) {
95 34
            return Html::ul()
96 34
                ->attributes($options);
97
        }
98
99 5
        $tagName = ArrayHelper::remove($options, 'tag', 'div');
100
101 5
        return Html::tag($tagName, '', $options)
102 5
            ->encode($this->encodeTags);
103
    }
104
105 1
    public function begin(): ?string
106
    {
107 1
        parent::begin();
108
109 1
        $options = $this->prepareOptions();
110 1
        $this->tagName = is_array($this->items) ? 'ul' : ArrayHelper::remove($options, 'tag', 'div');
111
112 1
        return Html::openTag($this->tagName, $options);
0 ignored issues
show
Bug introduced by
It seems like $this->tagName can also be of type null; however, parameter $name of Yiisoft\Html\Html::openTag() 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

112
        return Html::openTag(/** @scrutinizer ignore-type */ $this->tagName, $options);
Loading history...
113
    }
114
115
    /**
116
     * @return string
117
     * @throws InvalidConfigException
118
     * @throws JsonException
119
     */
120 40
    public function render(): string
121
    {
122 40
        if ($this->tagName) {
123 1
            return Html::closeTag($this->tagName);
124
        }
125
126 39
        $layout = $this->prepareDropdownLayout();
127
128 39
        if (is_array($this->items)) {
129
            /** @var Ul $layout */
130 34
            return $this->renderItems($layout, $this->items);
131
        }
132
        /** @var CustomTag $layout */
133 5
        return $layout
134 5
            ->content((string) $this->items)
135 5
            ->render();
136
    }
137
138
    /**
139
     * List of menu items in the dropdown. Each array element can be either an HTML string, or an array representing a
140
     * single menu with the following structure:
141
     *
142
     * - label: string, required, the label of the item link.
143
     * - encode: bool, optional, whether to HTML-encode item label.
144
     * - url: string|array, optional, the URL of the item link. This will be processed by {@see currentPath}.
145
     *   If not set, the item will be treated as a menu header when the item has no sub-menu.
146
     * - visible: bool, optional, whether this menu item is visible. Defaults to true.
147
     * - linkOptions: array, optional, the HTML attributes of the item link.
148
     * - options: array, optional, the HTML attributes of the item.
149
     * - items: array, optional, the submenu items. The structure is the same as this property.
150
     *   Note that Bootstrap doesn't support dropdown submenu. You have to add your own CSS styles to support it.
151
     * - submenuOptions: array, optional, the HTML attributes for sub-menu container tag. If specified it will be
152
     *   merged with {@see submenuOptions}.
153
     *
154
     * To insert divider use `-`.
155
     */
156 40
    public function items(array|string|Stringable|null $value): self
157
    {
158 40
        $new = clone $this;
159 40
        $new->items = $value;
160 40
        $new->tagName = null;
161
162 40
        return $new;
163
    }
164
165
    /**
166
     * Set dropdown alignment
167
     * @see https://getbootstrap.com/docs/5.3/components/dropdowns/#menu-alignment
168
     */
169 2
    public function withAlignment(string ...$alignment): self
170
    {
171 2
        $new = clone $this;
172 2
        $new->alignment = array_unique($alignment);
173
174 2
        return $new;
175
    }
176
177
    /**
178
     * When tags Labels HTML should not be encoded.
179
     */
180 5
    public function withoutEncodeLabels(): self
181
    {
182 5
        $new = clone $this;
183 5
        $new->encodeLabels = false;
184
185 5
        return $new;
186
    }
187
188 1
    public function withEncodeTags(bool $encode): self
189
    {
190 1
        $new = clone $this;
191 1
        $new->encodeTags = $encode;
192
193 1
        return $new;
194
    }
195
196
    /**
197
     * The HTML attributes for sub-menu container tags.
198
     */
199 4
    public function submenuOptions(array $value): self
200
    {
201 4
        $new = clone $this;
202 4
        $new->submenuOptions = $value;
203
204 4
        return $new;
205
    }
206
207
    /**
208
     * @param array $value the HTML attributes for the widget container tag. The following special options are
209
     * recognized.
210
     *
211
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
212
     */
213 19
    public function options(array $value): self
214
    {
215 19
        $new = clone $this;
216 19
        $new->options = $value;
217 19
        $new->tagName = null;
218
219 19
        return $new;
220
    }
221
222
    /**
223
     * Options for each item if not present in self
224
     */
225 1
    public function itemOptions(array $options): self
226
    {
227 1
        $new = clone $this;
228 1
        $new->itemOptions = $options;
229
230 1
        return $new;
231
    }
232
233
    /**
234
     * Options for each item link if not present in current item
235
     */
236 1
    public function linkOptions(array $options): self
237
    {
238 1
        $new = clone $this;
239 1
        $new->linkOptions = $options;
240
241 1
        return $new;
242
    }
243
244
    /**
245
     * Renders menu items.
246
     *
247
     * @param Ul $layout
248
     * @param array $items the menu items to be rendered
249
     *
250
     * @throws InvalidConfigException|JsonException|RuntimeException if the label option is not specified in one of the
251
     * items.
252
     *
253
     * @return string the rendering result.
254
     */
255 34
    private function renderItems(Ul $layout, array $items): string
256
    {
257 34
        $lines = [];
258
259 34
        foreach ($items as $item) {
260 34
            if (is_string($item)) {
261 4
                $item = ['label' => $item, 'encode' => false, 'enclose' => false];
262
            }
263
264 34
            if (isset($item['visible']) && !$item['visible']) {
265 3
                continue;
266
            }
267
268 34
            if (!array_key_exists('label', $item)) {
269 1
                throw new RuntimeException("The 'label' option is required.");
270
            }
271
272 33
            $lines[] = $this->renderItem($item);
273
        }
274
275 33
        return $layout
276 33
            ->items(...$lines)
277 33
            ->render();
278
    }
279
280
    /**
281
     * Render current dropdown item
282
     */
283 33
    private function renderItem(array $item): Li
284
    {
285 33
        $url = $item['url'] ?? null;
286 33
        $encodeLabel = $item['encode'] ?? $this->encodeLabels;
287 33
        $label = $encodeLabel ? Html::encode($item['label']) : $item['label'];
288 33
        $itemOptions = ArrayHelper::getValue($item, 'options', $this->itemOptions);
289 33
        $linkOptions = ArrayHelper::getValue($item, 'linkOptions', $this->linkOptions);
290 33
        $active = ArrayHelper::getValue($item, 'active', false);
291 33
        $disabled = ArrayHelper::getValue($item, 'disabled', false);
292 33
        $enclose = ArrayHelper::getValue($item, 'enclose', true);
293
294 33
        if ($url !== null) {
295 22
            Html::addCssClass($linkOptions, ['widget' => 'dropdown-item']);
296
297 22
            if ($disabled) {
298 2
                ArrayHelper::setValue($linkOptions, 'tabindex', '-1');
299 2
                ArrayHelper::setValue($linkOptions, 'aria-disabled', 'true');
300 2
                Html::addCssClass($linkOptions, ['disable' => 'disabled']);
301 22
            } elseif ($active) {
302 1
                Html::addCssClass($linkOptions, ['active' => 'active']);
303
            }
304
        }
305
306
        /** @psalm-suppress ConflictingReferenceConstraint */
307 33
        if (empty($item['items'])) {
308 33
            if ($url !== null) {
309 22
                $content = Html::a($label, $url, $linkOptions)->encode($this->encodeTags);
310 20
            } elseif ($label === '-') {
311 4
                Html::addCssClass($linkOptions, ['widget' => 'dropdown-divider']);
312 4
                $content = Html::tag('hr', '', $linkOptions);
313 18
            } elseif ($enclose === false) {
314 1
                $content = $label;
315
            } else {
316 17
                Html::addCssClass($linkOptions, ['widget' => 'dropdown-header']);
317 17
                $tag = ArrayHelper::remove($linkOptions, 'tag', 'h6');
318 17
                $content = Html::tag($tag, $label, $linkOptions);
319
            }
320
321 33
            return Li::tag()
322 33
                ->content($content)
323 33
                ->attributes($itemOptions)
324 33
                ->encode(false);
325
        }
326
327 4
        $submenuOptions = $this->submenuOptions;
328
329 4
        if (isset($item['submenuOptions'])) {
330 1
            $submenuOptions = array_merge($submenuOptions, $item['submenuOptions']);
331
        }
332
333 4
        Html::addCssClass($submenuOptions, ['submenu' => 'dropdown-menu']);
334 4
        Html::addCssClass($linkOptions, [
335 4
            'widget' => 'dropdown-item',
336 4
            'toggle' => 'dropdown-toggle',
337 4
        ]);
338
339 4
        $itemOptions = array_merge_recursive(['class' => ['dropdown'], 'aria-expanded' => 'false'], $itemOptions);
340
341 4
        $dropdown = self::widget()
342 4
            ->items($item['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\ButtonDropdown 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

342
            ->/** @scrutinizer ignore-call */ items($item['items'])
Loading history...
343 4
            ->options($submenuOptions)
344 4
            ->submenuOptions($submenuOptions);
345
346 4
        if ($this->encodeLabels === false) {
347 1
            $dropdown = $dropdown->withoutEncodeLabels();
348
        }
349
350 4
        ArrayHelper::setValue($linkOptions, 'data-bs-toggle', 'dropdown');
351 4
        ArrayHelper::setValue($linkOptions, 'data-bs-auto-close', 'outside');
352 4
        ArrayHelper::setValue($linkOptions, 'aria-haspopup', 'true');
353 4
        ArrayHelper::setValue($linkOptions, 'aria-expanded', 'false');
354 4
        ArrayHelper::setValue($linkOptions, 'role', 'button');
355
356 4
        $toggle = Html::a($label, $url, $linkOptions)->encode($this->encodeTags);
357
358 4
        return Li::tag()
359 4
            ->content($toggle . $dropdown->render())
360 4
            ->attributes($itemOptions)
361 4
            ->encode(false);
362
    }
363
}
364