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) { |
|
|
|
|
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']) |
|
|
|
|
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
|
|
|
|
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.