Passed
Pull Request — master (#123)
by
unknown
02:39
created

Dropdown::begin()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 8
ccs 5
cts 5
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 37
    public function getId(?string $suffix = '-dropdown'): ?string
64
    {
65 37
        return $this->options['id'] ?? parent::getId($suffix);
66
    }
67
68 37
    private function prepareOptions(): array
69
    {
70 37
        $options = $this->options;
71 37
        $options['id'] = $this->getId();
72
73
        /** @psalm-suppress InvalidArgument */
74 37
        Html::addCssClass($options, ['widget' => 'dropdown-menu']);
75
76 37
        if ($this->alignment) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->alignment of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
77 1
            Html::addCssClass($options, $this->alignment);
78
        }
79
80 37
        if ($this->darkTheme) {
81 1
            $options['data-bs-theme'] = 'dark';
82 1
            Html::addCssClass($options, 'dropdown-menu-dark');
83
        }
84
85 37
        return $options;
86
    }
87
88 36
    private function prepareDropdownLayout(): CustomTag|Ul
89
    {
90 36
        $options = $this->prepareOptions();
91
92 36
        if (is_array($this->items)) {
93 31
            return Html::ul()
94 31
                ->attributes($options);
95
        }
96
97 5
        $tagName = ArrayHelper::remove($options, 'tag', 'div');
98
99 5
        return Html::tag($tagName, '', $options)
100 5
            ->encode($this->encodeTags);
101
    }
102
103 1
    public function begin(): ?string
104
    {
105 1
        parent::begin();
106
107 1
        $options = $this->prepareOptions();
108 1
        $tagName = is_array($this->items) ? 'ul' : ArrayHelper::remove($options, 'tag', 'div');
109
110 1
        return Html::openTag($tagName, $options);
111
    }
112
113
    /**
114
     * @return string
115
     * @throws InvalidConfigException
116
     * @throws JsonException
117
     */
118 37
    public function render(): string
119
    {
120 37
        if ($this->items === null) {
121 1
            $tagName = $this->prepareOptions()['tag'] ?? 'div';
122
123 1
            return Html::closeTag($tagName);
124
        }
125
126 36
        $layout = $this->prepareDropdownLayout();
127
128 36
        if (is_array($this->items)) {
129
            /** @var Ul $layout */
130 31
            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 37
    public function items(array|string|Stringable|null $value): self
157
    {
158 37
        $new = clone $this;
159 37
        $new->items = $value;
160
161 37
        return $new;
162
    }
163
164
    /**
165
     * Set dropdown alignment
166
     * @see https://getbootstrap.com/docs/5.3/components/dropdowns/#menu-alignment
167
     */
168 1
    public function withAlignment(string ...$alignment): self
169
    {
170 1
        $new = clone $this;
171 1
        $new->alignment = array_unique($alignment);
172
173 1
        return $new;
174
    }
175
176
    /**
177
     * When tags Labels HTML should not be encoded.
178
     */
179 4
    public function withoutEncodeLabels(): self
180
    {
181 4
        $new = clone $this;
182 4
        $new->encodeLabels = false;
183
184 4
        return $new;
185
    }
186
187 1
    public function withEncodeTags(bool $encode): self
188
    {
189 1
        $new = clone $this;
190 1
        $new->encodeTags = $encode;
191
192 1
        return $new;
193
    }
194
195
    /**
196
     * The HTML attributes for sub-menu container tags.
197
     */
198 4
    public function submenuOptions(array $value): self
199
    {
200 4
        $new = clone $this;
201 4
        $new->submenuOptions = $value;
202
203 4
        return $new;
204
    }
205
206
    /**
207
     * @param array $value the HTML attributes for the widget container tag. The following special options are
208
     * recognized.
209
     *
210
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
211
     */
212 19
    public function options(array $value): self
213
    {
214 19
        $new = clone $this;
215 19
        $new->options = $value;
216
217 19
        return $new;
218
    }
219
220
    /**
221
     * Options for each item if not present in self
222
     */
223 1
    public function itemOptions(array $options): self
224
    {
225 1
        $new = clone $this;
226 1
        $new->itemOptions = $options;
227
228 1
        return $new;
229
    }
230
231
    /**
232
     * Options for each item link if not present in current item
233
     */
234 1
    public function linkOptions(array $options): self
235
    {
236 1
        $new = clone $this;
237 1
        $new->linkOptions = $options;
238
239 1
        return $new;
240
    }
241
242
    /**
243
     * Renders menu items.
244
     *
245
     * @param Ul $layout
246
     * @param array $items the menu items to be rendered
247
     *
248
     * @throws InvalidConfigException|JsonException|RuntimeException if the label option is not specified in one of the
249
     * items.
250
     *
251
     * @return string the rendering result.
252
     */
253 31
    private function renderItems(Ul $layout, array $items): string
254
    {
255 31
        $lines = [];
256
257 31
        foreach ($items as $item) {
258 31
            if (is_string($item)) {
259 4
                $item = ['label' => $item, 'encode' => false, 'enclose' => false];
260
            }
261
262 31
            if (isset($item['visible']) && !$item['visible']) {
263 3
                continue;
264
            }
265
266 31
            if (!array_key_exists('label', $item)) {
267 1
                throw new RuntimeException("The 'label' option is required.");
268
            }
269
270 30
            $lines[] = $this->renderItem($item);
271
        }
272
273 30
        return $layout
274 30
            ->items(...$lines)
275 30
            ->render();
276
    }
277
278
    /**
279
     * Render current dropdown item
280
     */
281 30
    private function renderItem(array $item): Li
282
    {
283 30
        $url = $item['url'] ?? null;
284 30
        $encodeLabel = $item['encode'] ?? $this->encodeLabels;
285 30
        $label = $encodeLabel ? Html::encode($item['label']) : $item['label'];
286 30
        $itemOptions = ArrayHelper::getValue($item, 'options', $this->itemOptions);
287 30
        $linkOptions = ArrayHelper::getValue($item, 'linkOptions', $this->linkOptions);
288 30
        $active = ArrayHelper::getValue($item, 'active', false);
289 30
        $disabled = ArrayHelper::getValue($item, 'disabled', false);
290 30
        $enclose = ArrayHelper::getValue($item, 'enclose', true);
291
292 30
        if ($url !== null) {
293 19
            Html::addCssClass($linkOptions, ['widget' => 'dropdown-item']);
294
295 19
            if ($disabled) {
296 2
                ArrayHelper::setValue($linkOptions, 'tabindex', '-1');
297 2
                ArrayHelper::setValue($linkOptions, 'aria-disabled', 'true');
298 2
                Html::addCssClass($linkOptions, ['disable' => 'disabled']);
299 19
            } elseif ($active) {
300 1
                Html::addCssClass($linkOptions, ['active' => 'active']);
301
            }
302
        }
303
304
        /** @psalm-suppress ConflictingReferenceConstraint */
305 30
        if (empty($item['items'])) {
306 30
            if ($url !== null) {
307 19
                $content = Html::a($label, $url, $linkOptions)->encode($this->encodeTags);
308 18
            } elseif ($label === '-') {
309 4
                Html::addCssClass($linkOptions, ['widget' => 'dropdown-divider']);
310 4
                $content = Html::tag('hr', '', $linkOptions);
311 16
            } elseif ($enclose === false) {
312 1
                $content = $label;
313
            } else {
314 15
                Html::addCssClass($linkOptions, ['widget' => 'dropdown-header']);
315 15
                $tag = ArrayHelper::remove($linkOptions, 'tag', 'h6');
316 15
                $content = Html::tag($tag, $label, $linkOptions);
317
            }
318
319 30
            return Li::tag()
320 30
                ->content($content)
321 30
                ->attributes($itemOptions)
322 30
                ->encode(false);
323
        }
324
325 4
        $submenuOptions = $this->submenuOptions;
326
327 4
        if (isset($item['submenuOptions'])) {
328 1
            $submenuOptions = array_merge($submenuOptions, $item['submenuOptions']);
329
        }
330
331 4
        Html::addCssClass($submenuOptions, ['submenu' => 'dropdown-menu']);
332 4
        Html::addCssClass($linkOptions, [
333 4
            'widget' => 'dropdown-item',
334 4
            'toggle' => 'dropdown-toggle',
335 4
        ]);
336
337 4
        $itemOptions = array_merge_recursive(['class' => ['dropdown'], 'aria-expanded' => 'false'], $itemOptions);
338
339 4
        $dropdown = self::widget()
340 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\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

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