vueless 1.2.8 → 1.2.10-beta.0

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.
Files changed (78) hide show
  1. package/constants.d.ts +4 -0
  2. package/constants.js +4 -0
  3. package/icons/storybook/rocket_launch.svg +1 -0
  4. package/index.d.ts +3 -1
  5. package/index.ts +3 -1
  6. package/package.json +9 -5
  7. package/plugin-vite.js +6 -1
  8. package/types.ts +14 -2
  9. package/ui.button/config.ts +4 -4
  10. package/ui.button/tests/UButton.test.ts +3 -3
  11. package/ui.button-toggle/config.ts +2 -2
  12. package/ui.container-accordion/UAccordion.vue +0 -1
  13. package/ui.container-accordion/config.ts +1 -1
  14. package/ui.container-accordion/storybook/stories.ts +13 -1
  15. package/ui.container-accordion-item/UAccordionItem.vue +17 -4
  16. package/ui.container-accordion-item/config.ts +1 -1
  17. package/ui.container-accordion-item/storybook/stories.ts +26 -1
  18. package/ui.container-accordion-item/tests/UAccordionItem.test.ts +186 -0
  19. package/ui.container-card/config.ts +1 -1
  20. package/ui.data-table/config.ts +4 -4
  21. package/ui.dropdown-badge/UDropdownBadge.vue +68 -3
  22. package/ui.dropdown-badge/config.ts +5 -1
  23. package/ui.dropdown-badge/storybook/stories.ts +280 -4
  24. package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +194 -0
  25. package/ui.dropdown-badge/types.ts +30 -0
  26. package/ui.dropdown-button/UDropdownButton.vue +69 -6
  27. package/ui.dropdown-button/config.ts +5 -1
  28. package/ui.dropdown-button/storybook/stories.ts +288 -3
  29. package/ui.dropdown-button/tests/UDropdownButton.test.ts +190 -0
  30. package/ui.dropdown-button/types.ts +30 -0
  31. package/ui.dropdown-link/UDropdownLink.vue +69 -6
  32. package/ui.dropdown-link/config.ts +5 -1
  33. package/ui.dropdown-link/storybook/stories.ts +281 -4
  34. package/ui.dropdown-link/tests/UDropdownLink.test.ts +194 -0
  35. package/ui.dropdown-link/types.ts +30 -0
  36. package/ui.form-calendar/config.ts +4 -2
  37. package/ui.form-checkbox/config.ts +1 -1
  38. package/ui.form-checkbox/tests/UCheckbox.test.ts +2 -2
  39. package/ui.form-checkbox-group/tests/UCheckboxGroup.test.ts +2 -2
  40. package/ui.form-date-picker-range/config.ts +1 -1
  41. package/ui.form-input/UInput.vue +4 -2
  42. package/ui.form-input/config.ts +1 -1
  43. package/ui.form-input/tests/UInput.test.ts +2 -2
  44. package/ui.form-input-counter/UInputCounter.vue +25 -1
  45. package/ui.form-input-counter/config.ts +7 -2
  46. package/ui.form-input-counter/tests/UInputCounter.test.ts +85 -1
  47. package/ui.form-input-counter/types.ts +25 -0
  48. package/ui.form-input-file/tests/UInputFile.test.ts +2 -2
  49. package/ui.form-input-number/UInputNumber.vue +15 -3
  50. package/ui.form-input-number/utilFormat.ts +17 -7
  51. package/ui.form-input-password/UInputPassword.vue +23 -1
  52. package/ui.form-label/ULabel.vue +10 -4
  53. package/ui.form-label/tests/ULabel.test.ts +29 -12
  54. package/ui.form-listbox/UListbox.vue +21 -9
  55. package/ui.form-listbox/config.ts +1 -1
  56. package/ui.form-listbox/storybook/stories.ts +188 -1
  57. package/ui.form-listbox/tests/UListbox.test.ts +36 -0
  58. package/ui.form-listbox/types.ts +5 -0
  59. package/ui.form-radio/config.ts +1 -1
  60. package/ui.form-radio/tests/URadio.test.ts +2 -2
  61. package/ui.form-radio-group/tests/URadioGroup.test.ts +2 -2
  62. package/ui.form-select/USelect.vue +20 -2
  63. package/ui.form-select/config.ts +2 -1
  64. package/ui.form-select/storybook/stories.ts +31 -4
  65. package/ui.form-select/tests/USelect.test.ts +143 -0
  66. package/ui.form-select/types.ts +10 -0
  67. package/ui.form-textarea/config.ts +1 -1
  68. package/ui.form-textarea/tests/UTextarea.test.ts +2 -2
  69. package/ui.text-alert/config.ts +1 -1
  70. package/ui.text-badge/config.ts +1 -1
  71. package/utils/helper.ts +4 -0
  72. package/utils/node/dynamicProps.d.ts +5 -2
  73. package/utils/node/dynamicProps.js +126 -53
  74. package/utils/node/helper.d.ts +10 -7
  75. package/utils/node/helper.js +59 -2
  76. package/utils/node/tailwindSafelist.js +9 -2
  77. package/utils/theme.ts +75 -31
  78. package/utils/ui.ts +32 -3
@@ -8,10 +8,21 @@ import {
8
8
 
9
9
  import UListbox from "../UListbox.vue";
10
10
  import URow from "../../ui.container-row/URow.vue";
11
+ import UCol from "../../ui.container-col/UCol.vue";
12
+ import UAvatar from "../../ui.image-avatar/UAvatar.vue";
13
+ import UIcon from "../../ui.image-icon/UIcon.vue";
14
+ import UBadge from "../../ui.text-badge/UBadge.vue";
15
+ import UText from "../../ui.text-block/UText.vue";
16
+ import ULoader from "../../ui.loader/ULoader.vue";
11
17
 
12
18
  import type { Meta, StoryFn } from "@storybook/vue3-vite";
13
19
  import type { Option, Props } from "../types";
14
20
 
21
+ import johnDoe from "../../ui.form-select/storybook/assets/images/john-doe.png";
22
+ import emilyDavis from "../../ui.form-select/storybook/assets/images/emily-davis.png";
23
+ import alexJohnson from "../../ui.form-select/storybook/assets/images/alex-johnson.png";
24
+ import patMorgan from "../../ui.form-select/storybook/assets/images/pat-morgan.png";
25
+
15
26
  interface DefaultUListboxArgs extends Props {
16
27
  slotTemplate?: string;
17
28
  }
@@ -47,7 +58,7 @@ export default {
47
58
  } as Meta;
48
59
 
49
60
  const DefaultTemplate: StoryFn<DefaultUListboxArgs> = (args: DefaultUListboxArgs) => ({
50
- components: { UListbox },
61
+ components: { UListbox, ULoader, UText, URow },
51
62
  setup: () => ({ args, slots: getSlotNames(UListbox.__name) }),
52
63
  template: `
53
64
  <UListbox
@@ -82,6 +93,9 @@ Default.args = {};
82
93
  export const Searchable = DefaultTemplate.bind({});
83
94
  Searchable.args = { searchable: true };
84
95
 
96
+ export const SearchModelValue = DefaultTemplate.bind({});
97
+ SearchModelValue.args = { search: "New York", searchable: true };
98
+
85
99
  export const Multiple = DefaultTemplate.bind({});
86
100
  Multiple.args = { multiple: true, modelValue: [] };
87
101
 
@@ -205,3 +219,176 @@ OptionSettings.parameters = {
205
219
  },
206
220
  },
207
221
  };
222
+
223
+ export const EmptySlot = DefaultTemplate.bind({});
224
+ EmptySlot.args = {
225
+ options: [],
226
+ slotTemplate: `
227
+ <template #empty>
228
+ <URow align="center">
229
+ <ULoader loading size="sm" />
230
+ <UText label="Loading, this may take a while..." />
231
+ </URow>
232
+ </template>
233
+ `,
234
+ };
235
+
236
+ export const OptionSlots: StoryFn<DefaultUListboxArgs> = (args) => ({
237
+ components: { UListbox, URow, UCol, UAvatar, UIcon, UBadge, UText },
238
+ setup: () => ({ args, johnDoe, emilyDavis, alexJohnson, patMorgan }),
239
+ template: `
240
+ <URow gap="lg">
241
+ <UListbox
242
+ v-model="args.beforeOptionModel"
243
+ class="static"
244
+ :options="[
245
+ {
246
+ label: 'John Doe',
247
+ id: '1',
248
+ role: 'Developer',
249
+ avatar: johnDoe,
250
+ status: 'online',
251
+ statusColor: 'success',
252
+ },
253
+ {
254
+ label: 'Jane Smith',
255
+ id: '2',
256
+ role: 'Designer',
257
+ avatar: emilyDavis,
258
+ status: 'away',
259
+ statusColor: 'warning',
260
+ },
261
+ {
262
+ label: 'Mike Johnson',
263
+ id: '3',
264
+ role: 'Product Manager',
265
+ avatar: alexJohnson,
266
+ status: 'offline',
267
+ statusColor: 'grayscale',
268
+ },
269
+ {
270
+ label: 'Sarah Wilson',
271
+ id: '4',
272
+ role: 'QA Engineer',
273
+ avatar: patMorgan,
274
+ status: 'online',
275
+ statusColor: 'success',
276
+ },
277
+ ]"
278
+ >
279
+ <template #before-option="{ option }">
280
+ <UAvatar :src="option.avatar" size="sm" />
281
+ </template>
282
+ </UListbox>
283
+
284
+ <UListbox
285
+ v-model="args.optionModel"
286
+ class="static"
287
+ :options="[
288
+ {
289
+ label: 'John Doe',
290
+ id: '1',
291
+ role: 'Developer',
292
+ avatar: johnDoe,
293
+ status: 'online',
294
+ statusColor: 'success',
295
+ },
296
+ {
297
+ label: 'Jane Smith',
298
+ id: '2',
299
+ role: 'Designer',
300
+ avatar: emilyDavis,
301
+ status: 'away',
302
+ statusColor: 'warning',
303
+ },
304
+ {
305
+ label: 'Mike Johnson',
306
+ id: '3',
307
+ role: 'Product Manager',
308
+ avatar: alexJohnson,
309
+ status: 'offline',
310
+ statusColor: 'grayscale',
311
+ },
312
+ {
313
+ label: 'Sarah Wilson',
314
+ id: '4',
315
+ role: 'QA Engineer',
316
+ avatar: patMorgan,
317
+ status: 'online',
318
+ statusColor: 'success',
319
+ },
320
+ ]"
321
+ >
322
+ <template #option="{ option }">
323
+ <URow align="center" gap="xs">
324
+ <UCol gap="none">
325
+ <UText size="sm">{{ option.label }}</UText>
326
+ <UText variant="lifted" size="xs">{{ option.role }}</UText>
327
+ </UCol>
328
+ <UBadge
329
+ :label="option.status"
330
+ :color="option.statusColor"
331
+ size="sm"
332
+ variant="subtle"
333
+ />
334
+ </URow>
335
+ </template>
336
+ </UListbox>
337
+
338
+ <UListbox
339
+ v-model="args.afterOptionModel"
340
+ class="static"
341
+ :options="[
342
+ {
343
+ label: 'John Doe',
344
+ id: '1',
345
+ role: 'Developer',
346
+ avatar: johnDoe,
347
+ status: 'online',
348
+ statusColor: 'success',
349
+ },
350
+ {
351
+ label: 'Jane Smith',
352
+ id: '2',
353
+ role: 'Designer',
354
+ avatar: emilyDavis,
355
+ status: 'away',
356
+ statusColor: 'warning',
357
+ },
358
+ {
359
+ label: 'Mike Johnson',
360
+ id: '3',
361
+ role: 'Product Manager',
362
+ avatar: alexJohnson,
363
+ status: 'offline',
364
+ statusColor: 'grayscale',
365
+ },
366
+ {
367
+ label: 'Sarah Wilson',
368
+ id: '4',
369
+ role: 'QA Engineer',
370
+ avatar: patMorgan,
371
+ status: 'online',
372
+ statusColor: 'success',
373
+ },
374
+ ]"
375
+ >
376
+ <template #after-option="{ option }">
377
+ <UBadge
378
+ :label="option.status"
379
+ :color="option.statusColor"
380
+ size="sm"
381
+ variant="subtle"
382
+ />
383
+ </template>
384
+ </UListbox>
385
+ </URow>
386
+ `,
387
+ });
388
+ OptionSlots.parameters = {
389
+ docs: {
390
+ story: {
391
+ height: "300px",
392
+ },
393
+ },
394
+ };
@@ -417,6 +417,41 @@ describe("UListbox.vue", () => {
417
417
  });
418
418
 
419
419
  describe("Functionality", () => {
420
+ it("Search v-model – filters options using external search prop", async () => {
421
+ const targetValue = "Option 2";
422
+
423
+ const component = mount(UListbox, {
424
+ props: {
425
+ searchable: true,
426
+ options: defaultOptions,
427
+ search: targetValue,
428
+ },
429
+ });
430
+
431
+ await flushPromises();
432
+
433
+ const options = component.findAll('[vl-key="option"]');
434
+
435
+ expect(options).toHaveLength(1);
436
+ expect(options[0].text()).toBe(targetValue);
437
+ });
438
+
439
+ it("Search v-model – emits update:search on input change", async () => {
440
+ const component = mount(UListbox, {
441
+ props: {
442
+ searchable: true,
443
+ options: defaultOptions,
444
+ },
445
+ });
446
+
447
+ const searchInput = component.getComponent(UInputSearch);
448
+
449
+ await searchInput.setValue("Option 3");
450
+
451
+ expect(component.emitted("update:search")).toBeTruthy();
452
+ expect(component.emitted("update:search")![0][0]).toBe("Option 3");
453
+ });
454
+
420
455
  it("Search – filters options based on search input", async () => {
421
456
  const targetValue = "Option 1";
422
457
  const filteredOptionsAmount = 1;
@@ -431,6 +466,7 @@ describe("UListbox.vue", () => {
431
466
  const searchInput = component.getComponent(UInputSearch);
432
467
 
433
468
  await searchInput.setValue(targetValue);
469
+ await flushPromises();
434
470
 
435
471
  const options = component.findAll('[vl-key="option"]');
436
472
 
@@ -24,6 +24,11 @@ export interface Props {
24
24
  */
25
25
  modelValue?: string | number | UnknownObject | (string | number | UnknownObject)[];
26
26
 
27
+ /**
28
+ * Search input model value.
29
+ */
30
+ search?: string;
31
+
27
32
  /**
28
33
  * List options.
29
34
  */
@@ -3,7 +3,7 @@ export default /*tw*/ {
3
3
  radio: {
4
4
  base: `
5
5
  bg-default cursor-pointer transition
6
- border border-default rounded-full outline-transparent
6
+ border border-solid border-default rounded-full outline-transparent
7
7
  appearance-none p-0 print:color-adjust-exact inline-block align-middle bg-origin-border select-none shrink-0
8
8
  hover:border-lifted hover:checked:border-{color}
9
9
  active:border-{color} active:bg-{color}/15
@@ -183,7 +183,7 @@ describe("URadio.vue", () => {
183
183
  });
184
184
 
185
185
  const labelComponent = component.getComponent(ULabel);
186
- const labelElement = labelComponent.find("label");
186
+ const labelElement = labelComponent.find("[vl-child-key='label']");
187
187
 
188
188
  expect(labelElement.text()).toBe(customLabelContent);
189
189
  });
@@ -201,7 +201,7 @@ describe("URadio.vue", () => {
201
201
  });
202
202
 
203
203
  const labelComponent = component.getComponent(ULabel);
204
- const labelElement = labelComponent.find("label");
204
+ const labelElement = labelComponent.find("[vl-child-key='label']");
205
205
 
206
206
  expect(labelElement.text()).toBe(`Modified ${defaultLabel}`);
207
207
  });
@@ -211,7 +211,7 @@ describe("URadioGroup.vue", () => {
211
211
  });
212
212
 
213
213
  const labelComponent = component.getComponent(ULabel);
214
- const labelElement = labelComponent.find("label");
214
+ const labelElement = labelComponent.find("[vl-child-key='label']");
215
215
 
216
216
  expect(labelElement.text()).toBe(customLabelContent);
217
217
  });
@@ -230,7 +230,7 @@ describe("URadioGroup.vue", () => {
230
230
  });
231
231
 
232
232
  const labelComponent = component.getComponent(ULabel);
233
- const labelElement = labelComponent.find("label");
233
+ const labelElement = labelComponent.find("[vl-child-key='label']");
234
234
 
235
235
  expect(labelElement.text()).toBe(`Modified ${defaultLabel}`);
236
236
  });
@@ -57,6 +57,12 @@ const emit = defineEmits([
57
57
  */
58
58
  "searchChange",
59
59
 
60
+ /**
61
+ * Triggers when the search v-model updates.
62
+ * @property {string} query
63
+ */
64
+ "update:search",
65
+
60
66
  /**
61
67
  * Triggers when the option from multiple select is removed.
62
68
  * @property {string} option
@@ -129,7 +135,7 @@ const dropdownValue = computed({
129
135
  emit("update:modelValue", value);
130
136
  emit("change", { value, options: props.options });
131
137
 
132
- if (!props.multiple) deactivate();
138
+ if (!props.multiple && props.closeOnSelect) deactivate();
133
139
  },
134
140
  });
135
141
 
@@ -181,6 +187,11 @@ const selectedOptionsLabel = computed(() => {
181
187
  };
182
188
  });
183
189
 
190
+ const dropdownSearch = computed({
191
+ get: () => props.search ?? "",
192
+ set: (value: string) => emit("update:search", value),
193
+ });
194
+
184
195
  const hiddenSelectedOptionsCount = computed(() => {
185
196
  return selectedOptions.value.hidden.length;
186
197
  });
@@ -255,6 +266,7 @@ function deactivate() {
255
266
  wrapperRef.value?.blur();
256
267
 
257
268
  isOpen.value = false;
269
+ dropdownSearch.value = "";
258
270
 
259
271
  nextTick(() => emit("close", localValue.value, elementId));
260
272
  }
@@ -472,7 +484,6 @@ const {
472
484
  <template>
473
485
  <ULabel
474
486
  ref="labelComponent"
475
- :for="elementId"
476
487
  :size="size"
477
488
  :label="label"
478
489
  :error="error"
@@ -797,6 +808,7 @@ const {
797
808
  v-if="isOpen"
798
809
  ref="listbox"
799
810
  v-model="dropdownValue as string | number"
811
+ v-model:search="dropdownSearch"
800
812
  :searchable="searchable"
801
813
  :options-limit="optionsLimit"
802
814
  :multiple="multiple"
@@ -818,6 +830,7 @@ const {
818
830
  @blur="onListboxBlur"
819
831
  @search-blur="onListboxSearchBlur"
820
832
  @search-change="onSearchChange"
833
+ @update:search="(value) => emit('update:search', value)"
821
834
  >
822
835
  <template #before-option="{ option, index }">
823
836
  <!--
@@ -845,6 +858,11 @@ const {
845
858
  -->
846
859
  <slot name="after-option" :option="option" :index="index" />
847
860
  </template>
861
+
862
+ <template #empty>
863
+ <!-- @slot Use it to add something instead of empty state. -->
864
+ <slot name="empty" />
865
+ </template>
848
866
  </UListbox>
849
867
 
850
868
  <div
@@ -12,7 +12,7 @@ export default /*tw*/ {
12
12
  wrapper: {
13
13
  base: `
14
14
  flex flex-row-reverse justify-between w-full min-h-full box-border relative
15
- rounded-medium border border-default bg-default outline-transparent
15
+ rounded-medium border border-solid border-default bg-default outline-transparent
16
16
  hover:border-lifted hover:transition hover:focus-within:border-primary focus-within:border-primary
17
17
  focus-within:outline focus-within:outline-small focus-within:outline-primary focus-within:transition
18
18
  `,
@@ -152,6 +152,7 @@ export default /*tw*/ {
152
152
  searchable: false,
153
153
  clearable: true,
154
154
  addOption: false,
155
+ closeOnSelect: true,
155
156
  /* icons */
156
157
  toggleIcon: "keyboard_arrow_down",
157
158
  clearIcon: "close_small",
@@ -15,6 +15,7 @@ import UIcon from "../../ui.image-icon/UIcon.vue";
15
15
  import ULink from "../../ui.button-link/ULink.vue";
16
16
  import UAvatar from "../../ui.image-avatar/UAvatar.vue";
17
17
  import UText from "../../ui.text-block/UText.vue";
18
+ import ULoader from "../../ui.loader/ULoader.vue";
18
19
 
19
20
  import johnDoe from "./assets/images/john-doe.png";
20
21
  import emilyDavis from "./assets/images/emily-davis.png";
@@ -59,7 +60,7 @@ export default {
59
60
  } as Meta;
60
61
 
61
62
  const DefaultTemplate: StoryFn<USelectArgs> = (args: USelectArgs) => ({
62
- components: { USelect, UIcon, ULink, UText },
63
+ components: { USelect, UIcon, ULink, UText, ULoader, URow },
63
64
  setup: () => ({ args, slots: getSlotNames(USelect.__name) }),
64
65
  template: `
65
66
  <USelect
@@ -148,6 +149,19 @@ NotClearable.args = { clearable: false };
148
149
  export const Searchable = DefaultTemplate.bind({});
149
150
  Searchable.args = { searchable: true };
150
151
 
152
+ export const SearchModelValue = DefaultTemplate.bind({});
153
+ SearchModelValue.args = { search: "New York", searchable: true };
154
+ SearchModelValue.parameters = {
155
+ docs: {
156
+ story: {
157
+ height: "350px",
158
+ },
159
+ },
160
+ };
161
+
162
+ export const NoCloseOnSelect = DefaultTemplate.bind({});
163
+ NoCloseOnSelect.args = { modelValue: 3, closeOnSelect: false };
164
+
151
165
  export const Readonly = DefaultTemplate.bind({});
152
166
  Readonly.args = { readonly: true, modelValue: "1", clearable: false };
153
167
 
@@ -212,9 +226,9 @@ GroupValue.parameters = {
212
226
  },
213
227
  };
214
228
 
215
- export const OptionsLimit2 = DefaultTemplate.bind({});
216
- OptionsLimit2.args = { optionsLimit: 2 };
217
- OptionsLimit2.parameters = {
229
+ export const OptionsLimit = DefaultTemplate.bind({});
230
+ OptionsLimit.args = { optionsLimit: 2 };
231
+ OptionsLimit.parameters = {
218
232
  docs: {
219
233
  description: {
220
234
  story: "`optionsLimit` prop controls the number of options displayed in the dropdown.",
@@ -530,6 +544,19 @@ SelectedCounterSlot.args = {
530
544
  `,
531
545
  };
532
546
 
547
+ export const EmptySlot = DefaultTemplate.bind({});
548
+ EmptySlot.args = {
549
+ options: [],
550
+ slotTemplate: `
551
+ <template #empty>
552
+ <URow align="center">
553
+ <ULoader loading size="sm" />
554
+ <UText label="Loading, this may take a while..." />
555
+ </URow>
556
+ </template>
557
+ `,
558
+ };
559
+
533
560
  export const OptionSlots: StoryFn<USelectArgs> = (args) => ({
534
561
  components: { USelect, URow, UCol, UAvatar, UIcon, UBadge, UText },
535
562
  setup: () => ({ args, johnDoe, emilyDavis, alexJohnson, patMorgan }),
@@ -255,6 +255,25 @@ describe("USelect.vue", () => {
255
255
  expect(component.getComponent(UListbox).props("searchable")).toBe(true);
256
256
  });
257
257
 
258
+ it("Search v-model – passes search to UListbox and filters", async () => {
259
+ const component = mount(USelect, {
260
+ props: {
261
+ searchable: true,
262
+ options: defaultOptions,
263
+ search: "Option 2",
264
+ },
265
+ });
266
+
267
+ await component.get("[role='combobox']").trigger("focus");
268
+ await flushPromises();
269
+
270
+ const listbox = component.getComponent(UListbox);
271
+ const options = listbox.findAll("[vl-child-key='option']");
272
+
273
+ expect(options).toHaveLength(1);
274
+ expect(options[0].text()).toBe("Option 2");
275
+ });
276
+
258
277
  it("Clearable – renders clear icon when true", async () => {
259
278
  const component = mount(USelect, {
260
279
  props: {
@@ -356,6 +375,24 @@ describe("USelect.vue", () => {
356
375
  expect(component.getComponent(UListbox).props("valueKey")).toBe(valueKey);
357
376
  });
358
377
 
378
+ it("Keeps dropdown open when closeOnSelect is false", async () => {
379
+ const component = mount(USelect, {
380
+ props: {
381
+ modelValue: "",
382
+ options: defaultOptions,
383
+ closeOnSelect: false,
384
+ },
385
+ });
386
+
387
+ await component.get("[role='combobox']").trigger("focus");
388
+
389
+ const firstOption = component.find("[vl-child-key='option']");
390
+
391
+ await firstOption.trigger("click");
392
+
393
+ expect(component.findComponent(UListbox).exists()).toBe(true);
394
+ });
395
+
359
396
  it("Options Limit – passes optionsLimit prop to UListbox", async () => {
360
397
  const optionsLimit = 5;
361
398
 
@@ -659,6 +696,93 @@ describe("USelect.vue", () => {
659
696
 
660
697
  expect(component.text()).toContain(slotContent);
661
698
  });
699
+
700
+ it("Before Option – renders custom content from before-option slot", async () => {
701
+ const slotContent = "Before Option";
702
+ const slotClass = "before-option-content";
703
+
704
+ const component = mount(USelect, {
705
+ props: {
706
+ options: defaultOptions,
707
+ },
708
+ slots: {
709
+ "before-option": `<span class='${slotClass}'>${slotContent}</span>`,
710
+ },
711
+ });
712
+
713
+ await component.get("[role='combobox']").trigger("focus");
714
+
715
+ const listbox = component.findComponent(UListbox);
716
+ const beforeOptionSlot = listbox.find(`.${slotClass}`);
717
+
718
+ expect(beforeOptionSlot.exists()).toBe(true);
719
+ expect(beforeOptionSlot.text()).toBe(slotContent);
720
+ });
721
+
722
+ it("Option – renders custom content from option slot", async () => {
723
+ const slotClass = "custom-option-content";
724
+
725
+ const component = mount(USelect, {
726
+ props: {
727
+ options: defaultOptions,
728
+ },
729
+ slots: {
730
+ option: `<span class='${slotClass}'>Custom {{ params.option.label }}</span>`,
731
+ },
732
+ });
733
+
734
+ await component.get("[role='combobox']").trigger("focus");
735
+
736
+ const listbox = component.findComponent(UListbox);
737
+ const customOptionSlot = listbox.find(`.${slotClass}`);
738
+
739
+ expect(customOptionSlot.exists()).toBe(true);
740
+ expect(customOptionSlot.text()).toBe("Custom Option 1");
741
+ });
742
+
743
+ it("After Option – renders custom content from after-option slot", async () => {
744
+ const slotContent = "After Option";
745
+ const slotClass = "after-option-content";
746
+
747
+ const component = mount(USelect, {
748
+ props: {
749
+ options: defaultOptions,
750
+ },
751
+ slots: {
752
+ "after-option": `<span class='${slotClass}'>${slotContent}</span>`,
753
+ },
754
+ });
755
+
756
+ await component.get("[role='combobox']").trigger("focus");
757
+
758
+ const listbox = component.findComponent(UListbox);
759
+ const afterOptionSlot = listbox.find(`.${slotClass}`);
760
+
761
+ expect(afterOptionSlot.exists()).toBe(true);
762
+ expect(afterOptionSlot.text()).toBe(slotContent);
763
+ });
764
+
765
+ it("Empty – renders custom content from empty slot", async () => {
766
+ const slotContent = "No options available";
767
+ const slotClass = "custom-empty";
768
+
769
+ const component = mount(USelect, {
770
+ props: {
771
+ options: [],
772
+ },
773
+ slots: {
774
+ empty: `<span class='${slotClass}'>${slotContent}</span>`,
775
+ },
776
+ });
777
+
778
+ await component.get("[role='combobox']").trigger("focus");
779
+
780
+ const listbox = component.findComponent(UListbox);
781
+ const emptySlot = listbox.find(`.${slotClass}`);
782
+
783
+ expect(emptySlot.exists()).toBe(true);
784
+ expect(emptySlot.text()).toBe(slotContent);
785
+ });
662
786
  });
663
787
 
664
788
  describe("Events", () => {
@@ -728,6 +852,25 @@ describe("USelect.vue", () => {
728
852
  vi.useRealTimers();
729
853
  });
730
854
 
855
+ it("Update:search – re-emits from UListbox", async () => {
856
+ const component = mount(USelect, {
857
+ props: {
858
+ searchable: true,
859
+ options: defaultOptions,
860
+ },
861
+ });
862
+
863
+ await component.get("[role='combobox']").trigger("focus");
864
+
865
+ const input = component.get("input");
866
+
867
+ await input.setValue("Option 3");
868
+ await flushPromises();
869
+
870
+ expect(component.emitted("update:search")).toBeTruthy();
871
+ expect(component.emitted("update:search")![0][0]).toBe("Option 3");
872
+ });
873
+
731
874
  it("Clear – emits when clear icon is clicked", async () => {
732
875
  const component = mount(USelect, {
733
876
  props: {
@@ -51,6 +51,16 @@ export interface Props {
51
51
  */
52
52
  debounce?: number | string;
53
53
 
54
+ /**
55
+ * Search input model value for the dropdown list.
56
+ */
57
+ search?: string;
58
+
59
+ /**
60
+ * Close dropdown on option select.
61
+ */
62
+ closeOnSelect?: boolean;
63
+
54
64
  /**
55
65
  * Left icon name.
56
66
  */
@@ -15,7 +15,7 @@ export default /*tw*/ {
15
15
  wrapper: {
16
16
  base: `
17
17
  flex px-3 py-2 gap-3 w-full bg-default transition
18
- rounded-medium border border-default outline-transparent
18
+ rounded-medium border border-solid border-default outline-transparent
19
19
  hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
20
20
  focus-within:outline focus-within:outline-small focus-within:outline-primary focus-within:transition
21
21
  `,