vueless 1.2.8-beta.3 → 1.2.8-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,12 @@ const emit = defineEmits([
55
55
  * @property {string} query
56
56
  */
57
57
  "searchChange",
58
+
59
+ /**
60
+ * Triggers when the search v-model updates.
61
+ * @property {string} query
62
+ */
63
+ "update:search",
58
64
  ]);
59
65
 
60
66
  provide("hideDropdownOptions", hideOptions);
@@ -78,6 +84,11 @@ const dropdownValue = computed({
78
84
  set: (value) => emit("update:modelValue", value),
79
85
  });
80
86
 
87
+ const dropdownSearch = computed({
88
+ get: () => props.search ?? "",
89
+ set: (value: string) => emit("update:search", value),
90
+ });
91
+
81
92
  const selectedOptions = computed(() => {
82
93
  if (props.multiple) {
83
94
  return props.options.filter((option) => {
@@ -139,7 +150,7 @@ function getFullOptionLabels(value: Option | Option[]) {
139
150
  function onClickOption(option: Option) {
140
151
  emit("clickOption", option);
141
152
 
142
- if (!props.multiple) hideOptions();
153
+ if (!props.multiple && props.closeOnSelect) hideOptions();
143
154
  }
144
155
 
145
156
  function onClickButton() {
@@ -245,16 +256,54 @@ const { getDataTest, config, dropdownButtonAttrs, listboxAttrs, toggleIconAttrs,
245
256
  v-if="isShownOptions"
246
257
  ref="dropdown-list"
247
258
  v-model="dropdownValue"
259
+ v-model:search="dropdownSearch"
248
260
  :searchable="searchable"
249
261
  :multiple="multiple"
250
262
  :color="color"
251
263
  :options="options"
264
+ :options-limit="optionsLimit"
265
+ :visible-options="visibleOptions"
252
266
  :label-key="labelKey"
253
267
  :value-key="valueKey"
268
+ :group-label-key="groupLabelKey"
269
+ :group-value-key="groupValueKey"
254
270
  v-bind="listboxAttrs"
255
271
  :data-test="getDataTest('list')"
256
272
  @click-option="onClickOption"
257
273
  @search-change="onSearchChange"
258
- />
274
+ @update:search="(value) => emit('update:search', value)"
275
+ >
276
+ <template #before-option="{ option, index }">
277
+ <!--
278
+ @slot Use it to add something before option.
279
+ @binding {object} option
280
+ @binding {number} index
281
+ -->
282
+ <slot name="before-option" :option="option" :index="index" />
283
+ </template>
284
+
285
+ <template #option="{ option, index }">
286
+ <!--
287
+ @slot Use it to customize the option.
288
+ @binding {object} option
289
+ @binding {number} index
290
+ -->
291
+ <slot name="option" :option="option" :index="index" />
292
+ </template>
293
+
294
+ <template #after-option="{ option, index }">
295
+ <!--
296
+ @slot Use it to add something after option.
297
+ @binding {object} option
298
+ @binding {number} index
299
+ -->
300
+ <slot name="after-option" :option="option" :index="index" />
301
+ </template>
302
+
303
+ <template #empty>
304
+ <!-- @slot Use it to add something instead of empty state. -->
305
+ <slot name="empty" />
306
+ </template>
307
+ </UListbox>
259
308
  </div>
260
309
  </template>
@@ -51,15 +51,19 @@ export default /*tw*/ {
51
51
  variant: "solid",
52
52
  labelKey: "label",
53
53
  valueKey: "id",
54
+ groupLabelKey: "label",
54
55
  yPosition: "bottom",
55
56
  xPosition: "left",
57
+ optionsLimit: 0,
58
+ visibleOptions: 8,
59
+ labelDisplayCount: 2,
56
60
  searchable: false,
57
61
  round: false,
58
62
  block: false,
59
63
  square: false,
60
64
  disabled: false,
61
65
  multiple: false,
62
- labelDisplayCount: 2,
66
+ closeOnSelect: true,
63
67
  /* icons */
64
68
  toggleIcon: "keyboard_arrow_down",
65
69
  },
@@ -12,10 +12,18 @@ import UCol from "../../ui.container-col/UCol.vue";
12
12
  import UIcon from "../../ui.image-icon/UIcon.vue";
13
13
  import ULink from "../../ui.button-link/ULink.vue";
14
14
  import UAvatar from "../../ui.image-avatar/UAvatar.vue";
15
+ import UBadge from "../../ui.text-badge/UBadge.vue";
16
+ import UText from "../../ui.text-block/UText.vue";
17
+ import ULoader from "../../ui.loader/ULoader.vue";
15
18
 
16
19
  import type { Meta, StoryFn } from "@storybook/vue3-vite";
17
20
  import type { Props } from "../types";
18
21
 
22
+ import johnDoe from "../../ui.form-select/storybook/assets/images/john-doe.png";
23
+ import emilyDavis from "../../ui.form-select/storybook/assets/images/emily-davis.png";
24
+ import alexJohnson from "../../ui.form-select/storybook/assets/images/alex-johnson.png";
25
+ import patMorgan from "../../ui.form-select/storybook/assets/images/pat-morgan.png";
26
+
19
27
  interface DefaultUDropdownButtonArgs extends Props {
20
28
  slotTemplate?: string;
21
29
  }
@@ -54,7 +62,7 @@ export default {
54
62
  const DefaultTemplate: StoryFn<DefaultUDropdownButtonArgs> = (
55
63
  args: DefaultUDropdownButtonArgs,
56
64
  ) => ({
57
- components: { UDropdownButton, UIcon, ULink, UAvatar },
65
+ components: { UDropdownButton, UIcon, ULink, UAvatar, ULoader, URow, UText },
58
66
  setup: () => ({ args, slots: getSlotNames(UDropdownButton.__name) }),
59
67
  template: `
60
68
  <UDropdownButton v-bind="args">
@@ -111,6 +119,35 @@ const MultiEnumTemplate: StoryFn<EnumUDropdownButtonArgs> = (
111
119
  `,
112
120
  });
113
121
 
122
+ const GroupValuesTemplate: StoryFn<DefaultUDropdownButtonArgs> = (
123
+ args: DefaultUDropdownButtonArgs,
124
+ ) => ({
125
+ components: { UDropdownButton },
126
+ setup() {
127
+ return {
128
+ args,
129
+ };
130
+ },
131
+ template: `
132
+ <UDropdownButton
133
+ v-bind="args"
134
+ v-model="args.modelValue"
135
+ label="Single"
136
+ :config="{ listbox: 'min-w-[200px]' }"
137
+ class="max-w-96 mr-20"
138
+ />
139
+
140
+ <UDropdownButton
141
+ v-bind="args"
142
+ v-model="args.modelValueMultiple"
143
+ label="Multiple"
144
+ multiple
145
+ :config="{ listbox: 'min-w-[200px]' }"
146
+ class="mt-5 max-w-96"
147
+ />
148
+ `,
149
+ });
150
+
114
151
  export const Default = DefaultTemplate.bind({});
115
152
  Default.args = {};
116
153
  Default.parameters = {
@@ -121,6 +158,9 @@ Default.parameters = {
121
158
  },
122
159
  };
123
160
 
161
+ export const Disabled = DefaultTemplate.bind({});
162
+ Disabled.args = { disabled: true };
163
+
124
164
  export const Searchable = DefaultTemplate.bind({});
125
165
  Searchable.args = { searchable: true };
126
166
  Searchable.parameters = {
@@ -131,6 +171,27 @@ Searchable.parameters = {
131
171
  },
132
172
  };
133
173
 
174
+ export const SearchModelValue = DefaultTemplate.bind({});
175
+ SearchModelValue.args = { searchable: true, search: "Copy" };
176
+ SearchModelValue.parameters = {
177
+ docs: {
178
+ story: {
179
+ height: "250px",
180
+ },
181
+ },
182
+ };
183
+
184
+ export const NoCloseOnSelect = SelectableTemplate.bind({});
185
+ NoCloseOnSelect.args = {
186
+ modelValue: "pending",
187
+ options: [
188
+ { label: "Active", id: "active" },
189
+ { label: "Pending", id: "pending" },
190
+ { label: "Archived", id: "archived" },
191
+ ],
192
+ closeOnSelect: false,
193
+ };
194
+
134
195
  export const OptionSelection = SelectableTemplate.bind({});
135
196
  OptionSelection.args = {
136
197
  label: "Select status",
@@ -176,8 +237,59 @@ ListboxYPosition.parameters = {
176
237
  storyClasses: "h-[350px] flex items-center px-6 pt-8 pb-12",
177
238
  };
178
239
 
179
- export const Disabled = DefaultTemplate.bind({});
180
- Disabled.args = { disabled: true };
240
+ export const GroupValue = GroupValuesTemplate.bind({});
241
+ GroupValue.args = {
242
+ modelValue: "",
243
+ groupValueKey: "libs",
244
+ groupLabelKey: "language",
245
+ labelKey: "name",
246
+ valueKey: "name",
247
+ options: [
248
+ {
249
+ language: "Javascript",
250
+ libs: [{ name: "Vue.js" }, { name: "Adonis" }],
251
+ },
252
+ {
253
+ language: "Ruby",
254
+ libs: [
255
+ { name: "Frameworks", isSubGroup: true, level: 2 },
256
+ { name: "Rails", level: 3 },
257
+ { name: "Sinatra", level: 3 },
258
+ ],
259
+ },
260
+ {
261
+ language: "Other",
262
+ libs: [{ name: "Laravel" }, { name: "Phoenix" }],
263
+ },
264
+ ],
265
+ };
266
+ GroupValue.parameters = {
267
+ docs: {
268
+ story: {
269
+ height: "400px",
270
+ },
271
+ },
272
+ };
273
+
274
+ export const OptionsLimit = DefaultTemplate.bind({});
275
+ OptionsLimit.args = { optionsLimit: 2 };
276
+ OptionsLimit.parameters = {
277
+ docs: {
278
+ description: {
279
+ story: "`optionsLimit` prop controls the number of options displayed in the dropdown.",
280
+ },
281
+ },
282
+ };
283
+
284
+ export const VisibleOptions = DefaultTemplate.bind({});
285
+ VisibleOptions.args = { visibleOptions: 2 };
286
+ VisibleOptions.parameters = {
287
+ docs: {
288
+ description: {
289
+ story: "`visibleOptions` prop controls the number of options you can see without a scroll.",
290
+ },
291
+ },
292
+ };
181
293
 
182
294
  export const WithoutToggleIcon = Default.bind({});
183
295
  WithoutToggleIcon.args = { toggleIcon: false };
@@ -228,3 +340,176 @@ ToggleSlot.args = {
228
340
  </template>
229
341
  `,
230
342
  };
343
+
344
+ export const EmptySlot = DefaultTemplate.bind({});
345
+ EmptySlot.args = {
346
+ options: [],
347
+ slotTemplate: `
348
+ <template #empty>
349
+ <URow align="center">
350
+ <ULoader loading size="sm" />
351
+ <UText label="Loading, this may take a while..." />
352
+ </URow>
353
+ </template>
354
+ `,
355
+ };
356
+
357
+ export const OptionSlots: StoryFn<DefaultUDropdownButtonArgs> = (args) => ({
358
+ components: { UDropdownButton, URow, UCol, UAvatar, UIcon, UBadge, UText },
359
+ setup: () => ({ args, johnDoe, emilyDavis, alexJohnson, patMorgan }),
360
+ template: `
361
+ <URow>
362
+ <UDropdownButton
363
+ v-model="args.beforeOptionModel"
364
+ label="Before option slot"
365
+ :options="[
366
+ {
367
+ label: 'John Doe',
368
+ id: '1',
369
+ role: 'Developer',
370
+ avatar: johnDoe,
371
+ status: 'online',
372
+ statusColor: 'success',
373
+ },
374
+ {
375
+ label: 'Jane Smith',
376
+ id: '2',
377
+ role: 'Designer',
378
+ avatar: emilyDavis,
379
+ status: 'away',
380
+ statusColor: 'warning',
381
+ },
382
+ {
383
+ label: 'Mike Johnson',
384
+ id: '3',
385
+ role: 'Product Manager',
386
+ avatar: alexJohnson,
387
+ status: 'offline',
388
+ statusColor: 'grayscale',
389
+ },
390
+ {
391
+ label: 'Sarah Wilson',
392
+ id: '4',
393
+ role: 'QA Engineer',
394
+ avatar: patMorgan,
395
+ status: 'online',
396
+ statusColor: 'success',
397
+ },
398
+ ]"
399
+ >
400
+ <template #before-option="{ option }">
401
+ <UAvatar :src="option.avatar" size="sm" />
402
+ </template>
403
+ </UDropdownButton>
404
+
405
+ <UDropdownButton
406
+ v-model="args.optionModel"
407
+ label="Option slot"
408
+ :options="[
409
+ {
410
+ label: 'John Doe',
411
+ id: '1',
412
+ role: 'Developer',
413
+ avatar: johnDoe,
414
+ status: 'online',
415
+ statusColor: 'success',
416
+ },
417
+ {
418
+ label: 'Jane Smith',
419
+ id: '2',
420
+ role: 'Designer',
421
+ avatar: emilyDavis,
422
+ status: 'away',
423
+ statusColor: 'warning',
424
+ },
425
+ {
426
+ label: 'Mike Johnson',
427
+ id: '3',
428
+ role: 'Product Manager',
429
+ avatar: alexJohnson,
430
+ status: 'offline',
431
+ statusColor: 'grayscale',
432
+ },
433
+ {
434
+ label: 'Sarah Wilson',
435
+ id: '4',
436
+ role: 'QA Engineer',
437
+ avatar: patMorgan,
438
+ status: 'online',
439
+ statusColor: 'success',
440
+ },
441
+ ]"
442
+ >
443
+ <template #option="{ option }">
444
+ <URow align="center" gap="xs">
445
+ <UCol gap="none">
446
+ <UText size="sm">{{ option.label }}</UText>
447
+ <UText variant="lifted" size="xs">{{ option.role }}</UText>
448
+ </UCol>
449
+ <UBadge
450
+ :label="option.status"
451
+ :color="option.statusColor"
452
+ size="sm"
453
+ variant="subtle"
454
+ />
455
+ </URow>
456
+ </template>
457
+ </UDropdownButton>
458
+
459
+ <UDropdownButton
460
+ v-model="args.afterOptionModel"
461
+ label="After option slot"
462
+ :options="[
463
+ {
464
+ label: 'John Doe',
465
+ id: '1',
466
+ role: 'Developer',
467
+ avatar: johnDoe,
468
+ status: 'online',
469
+ statusColor: 'success',
470
+ },
471
+ {
472
+ label: 'Jane Smith',
473
+ id: '2',
474
+ role: 'Designer',
475
+ avatar: emilyDavis,
476
+ status: 'away',
477
+ statusColor: 'warning',
478
+ },
479
+ {
480
+ label: 'Mike Johnson',
481
+ id: '3',
482
+ role: 'Product Manager',
483
+ avatar: alexJohnson,
484
+ status: 'offline',
485
+ statusColor: 'grayscale',
486
+ },
487
+ {
488
+ label: 'Sarah Wilson',
489
+ id: '4',
490
+ role: 'QA Engineer',
491
+ avatar: patMorgan,
492
+ status: 'online',
493
+ statusColor: 'success',
494
+ },
495
+ ]"
496
+ >
497
+ <template #after-option="{ option }">
498
+ <UBadge
499
+ :label="option.status"
500
+ :color="option.statusColor"
501
+ size="sm"
502
+ variant="subtle"
503
+ />
504
+ </template>
505
+ </UDropdownButton>
506
+ </URow>
507
+ `,
508
+ });
509
+ OptionSlots.parameters = {
510
+ docs: {
511
+ story: {
512
+ height: "300px",
513
+ },
514
+ },
515
+ };
@@ -300,6 +300,97 @@ describe("UDropdownButton.vue", () => {
300
300
 
301
301
  expect(component.findComponent(UButton).attributes("data-test")).toBe(dataTest);
302
302
  });
303
+
304
+ // OptionsLimit prop
305
+ it("passes optionsLimit prop to UListbox component", async () => {
306
+ const optionsLimit = 2;
307
+
308
+ const component = mount(UDropdownButton, {
309
+ props: {
310
+ optionsLimit,
311
+ options: defaultOptions,
312
+ },
313
+ });
314
+
315
+ await component.findComponent(UButton).trigger("click");
316
+
317
+ expect(component.findComponent(UListbox).props("optionsLimit")).toBe(optionsLimit);
318
+ });
319
+
320
+ it("Search v-model – passes search to UListbox and filters", async () => {
321
+ const component = mount(UDropdownButton, {
322
+ props: {
323
+ searchable: true,
324
+ options: defaultOptions,
325
+ search: "Option 2",
326
+ },
327
+ });
328
+
329
+ await component.findComponent(UButton).trigger("click");
330
+
331
+ const listbox = component.getComponent(UListbox);
332
+ const options = listbox.findAll("[vl-child-key='option']");
333
+
334
+ expect(options).toHaveLength(1);
335
+ expect(options[0].text()).toBe("Option 2");
336
+ });
337
+
338
+ // VisibleOptions prop
339
+ it("passes visibleOptions prop to UListbox component", async () => {
340
+ const visibleOptions = 5;
341
+
342
+ const component = mount(UDropdownButton, {
343
+ props: {
344
+ visibleOptions,
345
+ options: defaultOptions,
346
+ },
347
+ });
348
+
349
+ await component.findComponent(UButton).trigger("click");
350
+
351
+ expect(component.findComponent(UListbox).props("visibleOptions")).toBe(visibleOptions);
352
+ });
353
+
354
+ // GroupLabelKey prop
355
+ it("passes groupLabelKey prop to UListbox component", async () => {
356
+ const groupLabelKey = "category";
357
+ const groupedOptions = [
358
+ { groupLabel: "Group 1", category: "group1" },
359
+ { label: "Option 1", id: "option1", category: "group1" },
360
+ { groupLabel: "Group 2", category: "group2" },
361
+ { label: "Option 2", id: "option2", category: "group2" },
362
+ ];
363
+
364
+ const component = mount(UDropdownButton, {
365
+ props: {
366
+ groupLabelKey,
367
+ options: groupedOptions,
368
+ },
369
+ });
370
+
371
+ await component.findComponent(UButton).trigger("click");
372
+
373
+ expect(component.findComponent(UListbox).props("groupLabelKey")).toBe(groupLabelKey);
374
+ });
375
+
376
+ // CloseOnSelect prop
377
+ it("keeps dropdown open when closeOnSelect is false", async () => {
378
+ const component = mount(UDropdownButton, {
379
+ props: {
380
+ options: defaultOptions,
381
+ closeOnSelect: false,
382
+ },
383
+ });
384
+
385
+ await component.findComponent(UButton).trigger("click");
386
+ expect(component.findComponent(UListbox).exists()).toBe(true);
387
+
388
+ const listbox = component.findComponent(UListbox);
389
+
390
+ listbox.vm.$emit("update:modelValue", 2);
391
+
392
+ expect(component.findComponent(UListbox).exists()).toBe(true);
393
+ });
303
394
  });
304
395
 
305
396
  // Slots tests
@@ -361,6 +452,105 @@ describe("UDropdownButton.vue", () => {
361
452
  expect(component.find(`.${slotClass}`).exists()).toBe(true);
362
453
  expect(component.find(`.${slotClass}`).text()).toBe(slotText);
363
454
  });
455
+
456
+ // Before-option slot
457
+ it("renders content from before-option slot", async () => {
458
+ const label = "Dropdown Button";
459
+ const slotText = "Before";
460
+ const slotClass = "before-option-content";
461
+
462
+ const component = mount(UDropdownButton, {
463
+ props: {
464
+ label,
465
+ options: defaultOptions,
466
+ },
467
+ slots: {
468
+ "before-option": `<span class='${slotClass}'>${slotText}</span>`,
469
+ },
470
+ });
471
+
472
+ await component.findComponent(UButton).trigger("click");
473
+
474
+ const listbox = component.findComponent(UListbox);
475
+ const beforeOptionSlot = listbox.find(`.${slotClass}`);
476
+
477
+ expect(beforeOptionSlot.exists()).toBe(true);
478
+ expect(beforeOptionSlot.text()).toBe(slotText);
479
+ });
480
+
481
+ // Option slot
482
+ it("renders custom content from option slot", async () => {
483
+ const label = "Dropdown Button";
484
+ const slotClass = "custom-option-content";
485
+
486
+ const component = mount(UDropdownButton, {
487
+ props: {
488
+ label,
489
+ options: defaultOptions,
490
+ },
491
+ slots: {
492
+ option: `<span class='${slotClass}'>Custom {{ params.option.label }}</span>`,
493
+ },
494
+ });
495
+
496
+ await component.findComponent(UButton).trigger("click");
497
+
498
+ const listbox = component.findComponent(UListbox);
499
+ const customOptionSlot = listbox.find(`.${slotClass}`);
500
+
501
+ expect(customOptionSlot.exists()).toBe(true);
502
+ expect(customOptionSlot.text()).toBe("Custom Option 1");
503
+ });
504
+
505
+ // After-option slot
506
+ it("renders content from after-option slot", async () => {
507
+ const label = "Dropdown Button";
508
+ const slotText = "After";
509
+ const slotClass = "after-option-content";
510
+
511
+ const component = mount(UDropdownButton, {
512
+ props: {
513
+ label,
514
+ options: defaultOptions,
515
+ },
516
+ slots: {
517
+ "after-option": `<span class='${slotClass}'>${slotText}</span>`,
518
+ },
519
+ });
520
+
521
+ await component.findComponent(UButton).trigger("click");
522
+
523
+ const listbox = component.findComponent(UListbox);
524
+ const afterOptionSlot = listbox.find(`.${slotClass}`);
525
+
526
+ expect(afterOptionSlot.exists()).toBe(true);
527
+ expect(afterOptionSlot.text()).toBe(slotText);
528
+ });
529
+
530
+ // Empty slot
531
+ it("renders custom content from empty slot", async () => {
532
+ const label = "Dropdown Button";
533
+ const slotContent = "No options available";
534
+ const slotClass = "custom-empty";
535
+
536
+ const component = mount(UDropdownButton, {
537
+ props: {
538
+ label,
539
+ options: [],
540
+ },
541
+ slots: {
542
+ empty: `<span class='${slotClass}'>${slotContent}</span>`,
543
+ },
544
+ });
545
+
546
+ await component.findComponent(UButton).trigger("click");
547
+
548
+ const listbox = component.findComponent(UListbox);
549
+ const emptySlot = listbox.find(`.${slotClass}`);
550
+
551
+ expect(emptySlot.exists()).toBe(true);
552
+ expect(emptySlot.text()).toBe(slotContent);
553
+ });
364
554
  });
365
555
 
366
556
  // Events tests
@@ -36,6 +36,26 @@ export interface Props {
36
36
  */
37
37
  valueKey?: string;
38
38
 
39
+ /**
40
+ * Set a name of the property containing the group label.
41
+ */
42
+ groupLabelKey?: string;
43
+
44
+ /**
45
+ * Set a name of the property containing the group values.
46
+ */
47
+ groupValueKey?: string;
48
+
49
+ /**
50
+ * Number of options displayed in the dropdown.
51
+ */
52
+ optionsLimit?: number;
53
+
54
+ /**
55
+ * Number of options you can see without a scroll.
56
+ */
57
+ visibleOptions?: number;
58
+
39
59
  /**
40
60
  * Button variant.
41
61
  */
@@ -70,6 +90,16 @@ export interface Props {
70
90
  */
71
91
  searchable?: boolean;
72
92
 
93
+ /**
94
+ * Search input model value for the dropdown list.
95
+ */
96
+ search?: string;
97
+
98
+ /**
99
+ * Close dropdown on option select.
100
+ */
101
+ closeOnSelect?: boolean;
102
+
73
103
  /**
74
104
  * Allows multiple selection.
75
105
  */