Issues (17)

src/Dropdown.php (2 issues)

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

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

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