@pequity/squirrel 6.0.6 → 6.0.8

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.
package/README.md CHANGED
@@ -76,7 +76,7 @@ Import and use the components you need in your Vue 3 project:
76
76
  </template>
77
77
 
78
78
  <script setup lang="ts">
79
- import { PBtn, PRingLoader } from '@pequity/squirrel';
79
+ import { PBtn } from '@pequity/squirrel';
80
80
  </script>
81
81
  ```
82
82
 
@@ -111,7 +111,7 @@ Then, in your consumer project's `.env.local` file, add an `VUE_APP_SQUIRREL_LOC
111
111
 
112
112
  Finally, in your project's `vite.config` file, add the following:
113
113
 
114
- > Heads up! The `vite.config.mts` file of the `pequity/frontendv2` already includes the following configuration.
114
+ > Heads up! The `vite.config.ts` file of the `pequity/frontendv2` already includes the following configuration.
115
115
 
116
116
  ```js
117
117
  import { defineConfig, searchForWorkspaceRoot } from 'vite';
@@ -74,14 +74,14 @@ const _sfc_main = /* @__PURE__ */ vue.defineComponent({
74
74
  }
75
75
  },
76
76
  /**
77
- * Set property of **items**’s text value
77
+ * Set property of **items**'s text value
78
78
  */
79
79
  itemText: {
80
80
  type: String,
81
81
  default: "text"
82
82
  },
83
83
  /**
84
- * Set property of **items**’s value - must be primitive.
84
+ * Set property of **items**'s value - must be primitive.
85
85
  */
86
86
  itemValue: {
87
87
  type: [String, Number],
@@ -160,9 +160,16 @@ const _sfc_main = /* @__PURE__ */ vue.defineComponent({
160
160
  disabledBy: {
161
161
  type: String,
162
162
  default: "disabled"
163
+ },
164
+ /**
165
+ * Enables the ability to create new items when no search results are found
166
+ */
167
+ creatable: {
168
+ type: Boolean,
169
+ default: false
163
170
  }
164
171
  },
165
- emits: ["update:modelValue", "select"],
172
+ emits: ["update:modelValue", "select", "create"],
166
173
  setup(__props, { emit: __emit }) {
167
174
  const emit = __emit;
168
175
  const props = __props;
@@ -264,6 +271,10 @@ const _sfc_main = /* @__PURE__ */ vue.defineComponent({
264
271
  destroyNavigationSvc();
265
272
  (_b = (_a = formControl.value) == null ? void 0 : _a.querySelector("button")) == null ? void 0 : _b.focus();
266
273
  };
274
+ const handleCreate = () => {
275
+ emit("create", search.value);
276
+ dropdownShow.value = false;
277
+ };
267
278
  return (_ctx, _cache) => {
268
279
  const _directive_close_popper = vue.resolveDirective("close-popper");
269
280
  return vue.openBlock(), vue.createElementBlock("div", {
@@ -398,7 +409,18 @@ const _sfc_main = /* @__PURE__ */ vue.defineComponent({
398
409
  !vue.unref(computedItems).length ? vue.renderSlot(_ctx.$slots, "no-items", { key: 0 }, () => [
399
410
  vue.createElementVNode("div", {
400
411
  class: vue.normalizeClass(["flex items-center justify-center", vue.unref(pSelectList.SIZES)[__props.size]])
401
- }, "No items found", 2)
412
+ }, [
413
+ __props.creatable && vue.unref(search) ? (vue.openBlock(), vue.createElementBlock("button", {
414
+ key: 0,
415
+ class: "hover:text-primary-hover flex items-center gap-2 font-medium text-p-blue-40",
416
+ onClick: handleCreate
417
+ }, [
418
+ vue.createVNode(pIcon_vue_vue_type_script_setup_true_lang._sfc_main, { icon: "fe:plus-circle" }),
419
+ vue.createTextVNode(" Add '" + vue.toDisplayString(vue.unref(search)) + "' ", 1)
420
+ ])) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [
421
+ vue.createTextVNode("No items found")
422
+ ], 64))
423
+ ], 2)
402
424
  ]) : vue.createCommentVNode("", true)
403
425
  ], 6)
404
426
  ], 16)
@@ -420,7 +442,7 @@ const _sfc_main = /* @__PURE__ */ vue.defineComponent({
420
442
  }, () => [
421
443
  vue.createElementVNode("div", _hoisted_3, vue.toDisplayString(__props.multiple && vue.unref(selectedItems).length > 1 ? `${vue.unref(selectedItems).length} option${vue.unref(selectedItems).length > 1 ? "s" : ""} selected` : vue.unref(selectedItems)[0][__props.itemText]), 1)
422
444
  ]),
423
- __props.clearable && vue.unref(internalValue).length ? (vue.openBlock(), vue.createElementBlock("button", {
445
+ __props.clearable && vue.unref(selectedItems).length ? (vue.openBlock(), vue.createElementBlock("button", {
424
446
  key: 2,
425
447
  class: vue.normalizeClass(["absolute top-1/2 flex -translate-y-1/2 items-center justify-center text-p-gray-40 hover:text-p-gray-60", [vue.unref(pSelectList.SIZES)[__props.size], CLEAR_BUTTON_SPACING[__props.size]]]),
426
448
  "aria-label": "Clear selection",
@@ -217,12 +217,17 @@ const useSelectList = (props, inputSearch, virtualizerRef, emit) => {
217
217
  emit("update:modelValue", toArrOfObjIfNeeded(toEmit));
218
218
  };
219
219
  const clearAll = () => {
220
- if (!props.multiple) return;
221
220
  search.value = "";
222
- const disabledItemsValues = internalItems.value.filter((item) => isDisabled(item)).map((item) => item[props.itemValue]);
223
- const selectedItemsValues = internalValue.value;
224
- const selectedDisabledItems = lodashEs.intersection(disabledItemsValues, selectedItemsValues);
225
- emit("update:modelValue", toArrOfObjIfNeeded(selectedDisabledItems));
221
+ if (props.multiple) {
222
+ const disabledItemsValues = internalItems.value.filter((item) => isDisabled(item)).map((item) => item[props.itemValue]);
223
+ const selectedItemsValues = internalValue.value;
224
+ const selectedDisabledItems = lodashEs.intersection(disabledItemsValues, selectedItemsValues);
225
+ emit("update:modelValue", toArrOfObjIfNeeded(selectedDisabledItems));
226
+ } else {
227
+ if (!isDisabled(selectedItems.value[0])) {
228
+ emit("update:modelValue", null);
229
+ }
230
+ }
226
231
  };
227
232
  vue.watch(
228
233
  () => props.items,
@@ -1,4 +1,4 @@
1
- import { defineComponent, ref, useAttrs, computed, watch, onMounted, onUnmounted, resolveDirective, openBlock, createElementBlock, normalizeClass, unref, normalizeStyle, toDisplayString, createCommentVNode, createVNode, mergeProps, withCtx, createElementVNode, isRef, Fragment, renderList, withDirectives, renderSlot, withModifiers, vShow } from "vue";
1
+ import { defineComponent, ref, useAttrs, computed, watch, onMounted, onUnmounted, resolveDirective, openBlock, createElementBlock, normalizeClass, unref, normalizeStyle, toDisplayString, createCommentVNode, createVNode, mergeProps, withCtx, createElementVNode, isRef, Fragment, renderList, withDirectives, renderSlot, createTextVNode, withModifiers, vShow } from "vue";
2
2
  import PDropdown from "../p-dropdown.js";
3
3
  import { _ as _sfc_main$1 } from "./p-icon.js";
4
4
  import PInputSearch from "../p-input-search.js";
@@ -73,14 +73,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
73
73
  }
74
74
  },
75
75
  /**
76
- * Set property of **items**’s text value
76
+ * Set property of **items**'s text value
77
77
  */
78
78
  itemText: {
79
79
  type: String,
80
80
  default: "text"
81
81
  },
82
82
  /**
83
- * Set property of **items**’s value - must be primitive.
83
+ * Set property of **items**'s value - must be primitive.
84
84
  */
85
85
  itemValue: {
86
86
  type: [String, Number],
@@ -159,9 +159,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
159
159
  disabledBy: {
160
160
  type: String,
161
161
  default: "disabled"
162
+ },
163
+ /**
164
+ * Enables the ability to create new items when no search results are found
165
+ */
166
+ creatable: {
167
+ type: Boolean,
168
+ default: false
162
169
  }
163
170
  },
164
- emits: ["update:modelValue", "select"],
171
+ emits: ["update:modelValue", "select", "create"],
165
172
  setup(__props, { emit: __emit }) {
166
173
  const emit = __emit;
167
174
  const props = __props;
@@ -263,6 +270,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
263
270
  destroyNavigationSvc();
264
271
  (_b = (_a = formControl.value) == null ? void 0 : _a.querySelector("button")) == null ? void 0 : _b.focus();
265
272
  };
273
+ const handleCreate = () => {
274
+ emit("create", search.value);
275
+ dropdownShow.value = false;
276
+ };
266
277
  return (_ctx, _cache) => {
267
278
  const _directive_close_popper = resolveDirective("close-popper");
268
279
  return openBlock(), createElementBlock("div", {
@@ -397,7 +408,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
397
408
  !unref(computedItems).length ? renderSlot(_ctx.$slots, "no-items", { key: 0 }, () => [
398
409
  createElementVNode("div", {
399
410
  class: normalizeClass(["flex items-center justify-center", unref(SIZES)[__props.size]])
400
- }, "No items found", 2)
411
+ }, [
412
+ __props.creatable && unref(search) ? (openBlock(), createElementBlock("button", {
413
+ key: 0,
414
+ class: "hover:text-primary-hover flex items-center gap-2 font-medium text-p-blue-40",
415
+ onClick: handleCreate
416
+ }, [
417
+ createVNode(_sfc_main$1, { icon: "fe:plus-circle" }),
418
+ createTextVNode(" Add '" + toDisplayString(unref(search)) + "' ", 1)
419
+ ])) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
420
+ createTextVNode("No items found")
421
+ ], 64))
422
+ ], 2)
401
423
  ]) : createCommentVNode("", true)
402
424
  ], 6)
403
425
  ], 16)
@@ -419,7 +441,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
419
441
  }, () => [
420
442
  createElementVNode("div", _hoisted_3, toDisplayString(__props.multiple && unref(selectedItems).length > 1 ? `${unref(selectedItems).length} option${unref(selectedItems).length > 1 ? "s" : ""} selected` : unref(selectedItems)[0][__props.itemText]), 1)
421
443
  ]),
422
- __props.clearable && unref(internalValue).length ? (openBlock(), createElementBlock("button", {
444
+ __props.clearable && unref(selectedItems).length ? (openBlock(), createElementBlock("button", {
423
445
  key: 2,
424
446
  class: normalizeClass(["absolute top-1/2 flex -translate-y-1/2 items-center justify-center text-p-gray-40 hover:text-p-gray-60", [unref(SIZES)[__props.size], CLEAR_BUTTON_SPACING[__props.size]]]),
425
447
  "aria-label": "Clear selection",
@@ -215,12 +215,17 @@ const useSelectList = (props, inputSearch, virtualizerRef, emit) => {
215
215
  emit("update:modelValue", toArrOfObjIfNeeded(toEmit));
216
216
  };
217
217
  const clearAll = () => {
218
- if (!props.multiple) return;
219
218
  search.value = "";
220
- const disabledItemsValues = internalItems.value.filter((item) => isDisabled(item)).map((item) => item[props.itemValue]);
221
- const selectedItemsValues = internalValue.value;
222
- const selectedDisabledItems = intersection(disabledItemsValues, selectedItemsValues);
223
- emit("update:modelValue", toArrOfObjIfNeeded(selectedDisabledItems));
219
+ if (props.multiple) {
220
+ const disabledItemsValues = internalItems.value.filter((item) => isDisabled(item)).map((item) => item[props.itemValue]);
221
+ const selectedItemsValues = internalValue.value;
222
+ const selectedDisabledItems = intersection(disabledItemsValues, selectedItemsValues);
223
+ emit("update:modelValue", toArrOfObjIfNeeded(selectedDisabledItems));
224
+ } else {
225
+ if (!isDisabled(selectedItems.value[0])) {
226
+ emit("update:modelValue", null);
227
+ }
228
+ }
224
229
  };
225
230
  watch(
226
231
  () => props.items,
@@ -262,14 +262,14 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
262
262
  validator(value: Size): boolean;
263
263
  };
264
264
  /**
265
- * Set property of **items**’s text value
265
+ * Set property of **items**'s text value
266
266
  */
267
267
  itemText: {
268
268
  type: StringConstructor;
269
269
  default: string;
270
270
  };
271
271
  /**
272
- * Set property of **items**’s value - must be primitive.
272
+ * Set property of **items**'s value - must be primitive.
273
273
  */
274
274
  itemValue: {
275
275
  type: (StringConstructor | NumberConstructor)[];
@@ -349,9 +349,17 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
349
349
  type: PropType<string>;
350
350
  default: string;
351
351
  };
352
+ /**
353
+ * Enables the ability to create new items when no search results are found
354
+ */
355
+ creatable: {
356
+ type: BooleanConstructor;
357
+ default: boolean;
358
+ };
352
359
  }>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
353
360
  select: (...args: any[]) => void;
354
361
  "update:modelValue": (...args: any[]) => void;
362
+ create: (...args: any[]) => void;
355
363
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
356
364
  modelValue: {
357
365
  type: PropType<ModelValue>;
@@ -392,14 +400,14 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
392
400
  validator(value: Size): boolean;
393
401
  };
394
402
  /**
395
- * Set property of **items**’s text value
403
+ * Set property of **items**'s text value
396
404
  */
397
405
  itemText: {
398
406
  type: StringConstructor;
399
407
  default: string;
400
408
  };
401
409
  /**
402
- * Set property of **items**’s value - must be primitive.
410
+ * Set property of **items**'s value - must be primitive.
403
411
  */
404
412
  itemValue: {
405
413
  type: (StringConstructor | NumberConstructor)[];
@@ -479,9 +487,17 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
479
487
  type: PropType<string>;
480
488
  default: string;
481
489
  };
490
+ /**
491
+ * Enables the ability to create new items when no search results are found
492
+ */
493
+ creatable: {
494
+ type: BooleanConstructor;
495
+ default: boolean;
496
+ };
482
497
  }>> & Readonly<{
483
498
  onSelect?: ((...args: any[]) => any) | undefined;
484
499
  "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined;
500
+ onCreate?: ((...args: any[]) => any) | undefined;
485
501
  }>, {
486
502
  size: "sm" | "md" | "lg";
487
503
  label: string;
@@ -505,6 +521,7 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
505
521
  placeholderSearch: string;
506
522
  selectedTopShown: boolean;
507
523
  disabledBy: string;
524
+ creatable: boolean;
508
525
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {
509
526
  formControl: HTMLDivElement;
510
527
  button: HTMLButtonElement;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@pequity/squirrel",
3
3
  "description": "Squirrel component library",
4
- "version": "6.0.6",
5
- "packageManager": "pnpm@9.15.3",
4
+ "version": "6.0.8",
5
+ "packageManager": "pnpm@9.15.4",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "preinstall": "npx only-allow pnpm",
@@ -53,54 +53,54 @@
53
53
  "@playwright/test": "^1.49.1",
54
54
  "@semantic-release/changelog": "^6.0.3",
55
55
  "@semantic-release/git": "^10.0.1",
56
- "@storybook/addon-a11y": "^8.4.7",
57
- "@storybook/addon-actions": "^8.4.7",
58
- "@storybook/addon-essentials": "^8.4.7",
59
- "@storybook/addon-interactions": "^8.4.7",
60
- "@storybook/addon-links": "^8.4.7",
61
- "@storybook/blocks": "^8.4.7",
62
- "@storybook/manager-api": "^8.4.7",
63
- "@storybook/test": "^8.4.7",
56
+ "@storybook/addon-a11y": "^8.5.0",
57
+ "@storybook/addon-actions": "^8.5.0",
58
+ "@storybook/addon-essentials": "^8.5.0",
59
+ "@storybook/addon-interactions": "^8.5.0",
60
+ "@storybook/addon-links": "^8.5.0",
61
+ "@storybook/blocks": "^8.5.0",
62
+ "@storybook/manager-api": "^8.5.0",
63
+ "@storybook/test": "^8.5.0",
64
64
  "@storybook/test-runner": "^0.21.0",
65
- "@storybook/theming": "^8.4.7",
66
- "@storybook/vue3": "^8.4.7",
67
- "@storybook/vue3-vite": "^8.4.7",
65
+ "@storybook/theming": "^8.5.0",
66
+ "@storybook/vue3": "^8.5.0",
67
+ "@storybook/vue3-vite": "^8.5.0",
68
68
  "@tanstack/vue-virtual": "3.11.2",
69
69
  "@types/jsdom": "^21.1.7",
70
70
  "@types/lodash-es": "^4.17.12",
71
- "@types/node": "^22.10.5",
71
+ "@types/node": "^22.10.7",
72
72
  "@vitejs/plugin-vue": "^5.2.1",
73
- "@vitest/coverage-v8": "^2.1.8",
73
+ "@vitest/coverage-v8": "^3.0.3",
74
74
  "@vue/compiler-sfc": "3.5.13",
75
75
  "@vue/test-utils": "^2.4.6",
76
76
  "@vuepic/vue-datepicker": "11.0.1",
77
77
  "autoprefixer": "^10.4.20",
78
78
  "dayjs": "1.11.13",
79
- "eslint": "^9.17.0",
79
+ "eslint": "^9.18.0",
80
80
  "eslint-plugin-storybook": "^0.11.2",
81
81
  "floating-vue": "5.2.2",
82
82
  "glob": "^11.0.1",
83
83
  "husky": "^9.1.7",
84
84
  "iconify-icon": "^2.3.0",
85
85
  "jsdom": "^26.0.0",
86
- "lint-staged": "^15.3.0",
86
+ "lint-staged": "^15.4.1",
87
87
  "lodash-es": "4.17.21",
88
88
  "make-coverage-badge": "^1.2.0",
89
- "postcss": "^8.4.49",
89
+ "postcss": "^8.5.1",
90
90
  "prettier": "^3.4.2",
91
- "prettier-plugin-tailwindcss": "^0.6.9",
91
+ "prettier-plugin-tailwindcss": "^0.6.10",
92
92
  "resolve-tspaths": "^0.8.23",
93
93
  "rimraf": "^6.0.1",
94
- "sass": "^1.83.1",
94
+ "sass": "^1.83.4",
95
95
  "semantic-release": "^24.2.1",
96
- "storybook": "^8.4.7",
96
+ "storybook": "^8.5.0",
97
97
  "svgo": "^3.3.2",
98
98
  "tailwindcss": "^3.4.17",
99
99
  "typescript": "5.7.3",
100
- "vite": "^6.0.7",
101
- "vitest": "^2.1.8",
100
+ "vite": "^6.0.11",
101
+ "vitest": "^3.0.3",
102
102
  "vue": "3.5.13",
103
- "vue-currency-input": "3.1.0",
103
+ "vue-currency-input": "3.2.1",
104
104
  "vue-router": "4.5.0",
105
105
  "vue-toastification": "2.0.0-rc.5",
106
106
  "vue-tsc": "2.2.0"
@@ -653,6 +653,17 @@ describe('PDropdownSelect.vue', () => {
653
653
  cleanup(wrapper);
654
654
  });
655
655
 
656
+ it('does not render clear button when there is no value selected', async () => {
657
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
658
+
659
+ const wrapper = createWrapper({ selected: null }, { clearable: true });
660
+
661
+ const clearButton = wrapper.find('button[aria-label="Clear selection"]');
662
+ expect(clearButton.exists()).toBe(false);
663
+
664
+ cleanup(wrapper);
665
+ });
666
+
656
667
  it('clears multiple selections when clearable is true', async () => {
657
668
  useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
658
669
 
@@ -666,4 +677,138 @@ describe('PDropdownSelect.vue', () => {
666
677
 
667
678
  cleanup(wrapper);
668
679
  });
680
+
681
+ it('clears a single selection select when clearable is true', async () => {
682
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(10));
683
+
684
+ const items = cloneDeep(filterListItems).slice(0, 10);
685
+ items[0].disabled = false;
686
+ const wrapper = createWrapper({ selected: 1, items }, { multiple: false, clearable: true });
687
+
688
+ expect(wrapper.vm.$data.selected).toEqual(1);
689
+
690
+ const clearButton = wrapper.find('button[aria-label="Clear selection"]');
691
+ expect(clearButton.exists()).toBe(true);
692
+
693
+ await clearButton.trigger('click');
694
+ expect(wrapper.vm.$data.selected).toEqual(null);
695
+
696
+ cleanup(wrapper);
697
+ });
698
+
699
+ it('does not clear a single selection select when the item is disabled', async () => {
700
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(10));
701
+
702
+ const items = cloneDeep(filterListItems).slice(0, 10);
703
+ items[0].disabled = true;
704
+ const wrapper = createWrapper({ selected: 1, items }, { multiple: false, clearable: true });
705
+
706
+ expect(wrapper.vm.$data.selected).toEqual(1);
707
+
708
+ const clearButton = wrapper.find('button[aria-label="Clear selection"]');
709
+ expect(clearButton.exists()).toBe(true);
710
+
711
+ await clearButton.trigger('click');
712
+ expect(wrapper.vm.$data.selected).toEqual(1);
713
+
714
+ cleanup(wrapper);
715
+ });
716
+
717
+ describe('creatable functionality', () => {
718
+ it('shows create option when no items match search and creatable is true', async () => {
719
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
720
+ const wrapper = createWrapper({ selected: null }, { searchable: true, creatable: true });
721
+ await wrapper.find('button').trigger('click');
722
+ await sleep(200);
723
+ const searchInput = wrapper.find('input.text-night');
724
+ await searchInput.setValue('New Item');
725
+ const createButton = wrapper.find('button.hover\\:text-primary-hover');
726
+ expect(createButton.exists()).toBe(true);
727
+ expect(createButton.text()).toBe("Add 'New Item'");
728
+ cleanup(wrapper);
729
+ });
730
+
731
+ it('does not show create option when creatable is false', async () => {
732
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
733
+ const wrapper = createWrapper({ selected: null }, { searchable: true, creatable: false });
734
+ await wrapper.find('button').trigger('click');
735
+ await sleep(200);
736
+ const searchInput = wrapper.find('input.text-night');
737
+ await searchInput.setValue('New Item');
738
+ const createButton = wrapper.find('button.hover\\:text-primary-hover');
739
+ expect(createButton.exists()).toBe(false);
740
+ expect(wrapper.text()).toContain('No items found');
741
+ cleanup(wrapper);
742
+ });
743
+
744
+ it('emits create event when clicking create option', async () => {
745
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
746
+ const wrapper = createWrapper({ selected: null }, { searchable: true, creatable: true });
747
+ await wrapper.find('button').trigger('click');
748
+ await sleep(200);
749
+ const searchInput = wrapper.find('input.text-night');
750
+ await searchInput.setValue('New Item');
751
+ const createButton = wrapper.find('button.hover\\:text-primary-hover');
752
+ await createButton.trigger('click');
753
+ const pDropdownSelectCmp = wrapper.findComponent(PDropdownSelect);
754
+ expect(pDropdownSelectCmp.emitted().create[0]).toEqual(['New Item']);
755
+ expect(wrapper.find('.pdropdown-stub-popper').exists()).toBe(false);
756
+ cleanup(wrapper);
757
+ });
758
+
759
+ it('does not update model value when creating new item', async () => {
760
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
761
+ const wrapper = createWrapper({ selected: null }, { searchable: true, creatable: true });
762
+ await wrapper.find('button').trigger('click');
763
+ await sleep(200);
764
+ const searchInput = wrapper.find('input.text-night');
765
+ await searchInput.setValue('New Item');
766
+ const createButton = wrapper.find('button.hover\\:text-primary-hover');
767
+ await createButton.trigger('click');
768
+ const pDropdownSelectCmp = wrapper.findComponent(PDropdownSelect);
769
+ expect(pDropdownSelectCmp.emitted()['update:modelValue']).toBeFalsy();
770
+ expect(wrapper.vm.$data.selected).toBe(null);
771
+ cleanup(wrapper);
772
+ });
773
+
774
+ it('does not create object value when valueIsObject is true', async () => {
775
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
776
+ const wrapper = createWrapper({ selected: null }, { searchable: true, creatable: true, valueIsObject: true });
777
+ await wrapper.find('button').trigger('click');
778
+ await sleep(200);
779
+ const searchInput = wrapper.find('input.text-night');
780
+ await searchInput.setValue('New Item');
781
+ const createButton = wrapper.find('button.hover\\:text-primary-hover');
782
+ await createButton.trigger('click');
783
+ const pDropdownSelectCmp = wrapper.findComponent(PDropdownSelect);
784
+ expect(pDropdownSelectCmp.emitted().create[0]).toEqual(['New Item']);
785
+ expect(pDropdownSelectCmp.emitted()['update:modelValue']).toBeFalsy();
786
+ expect(wrapper.vm.$data.selected).toBe(null);
787
+ cleanup(wrapper);
788
+ });
789
+
790
+ it('emits create event with search value when using custom itemValue and itemText', async () => {
791
+ useVirtualizer.mockImplementation(() => createMockedVirtualizer(20));
792
+ const wrapper = createWrapper(
793
+ { selected: null },
794
+ {
795
+ searchable: true,
796
+ creatable: true,
797
+ valueIsObject: true,
798
+ itemValue: 'customValue',
799
+ itemText: 'customText',
800
+ }
801
+ );
802
+ await wrapper.find('button').trigger('click');
803
+ await sleep(200);
804
+ const searchInput = wrapper.find('input.text-night');
805
+ await searchInput.setValue('New Item');
806
+ const createButton = wrapper.find('button.hover\\:text-primary-hover');
807
+ await createButton.trigger('click');
808
+ const pDropdownSelectCmp = wrapper.findComponent(PDropdownSelect);
809
+ expect(pDropdownSelectCmp.emitted().create[0]).toEqual(['New Item']);
810
+ expect(pDropdownSelectCmp.emitted()['update:modelValue']).toBeFalsy();
811
+ cleanup(wrapper);
812
+ });
813
+ });
669
814
  });
@@ -312,3 +312,39 @@ export const ClearableMultiple = {
312
312
  clearable: true,
313
313
  },
314
314
  };
315
+
316
+ export const Creatable = {
317
+ render: (args) => ({
318
+ components: { PDropdownSelect },
319
+ setup() {
320
+ const value = ref(null);
321
+ return { args, value };
322
+ },
323
+ template: `
324
+ <div>
325
+ <PDropdownSelect
326
+ v-model="value"
327
+ v-bind="args"
328
+ @create="(searchTerm) => alert('Create item: ' + searchTerm)"
329
+ />
330
+ <div class="mt-4">Selected: {{ value }}</div>
331
+ </div>
332
+ `,
333
+ }),
334
+ parameters: {
335
+ docs: {
336
+ description: {
337
+ story:
338
+ 'Example with creatable functionality enabled. When no search results are found, an option to create a new item is shown.',
339
+ },
340
+ },
341
+ },
342
+ args: {
343
+ ...Default.args,
344
+ label: 'Creatable dropdown',
345
+ items: items2,
346
+ searchable: true,
347
+ creatable: true,
348
+ placeholder: 'Search or create an item...',
349
+ },
350
+ };
@@ -38,9 +38,9 @@
38
38
  }}
39
39
  </div>
40
40
  </slot>
41
- <!-- Add clear button -->
41
+ <!-- Clear selection button -->
42
42
  <button
43
- v-if="clearable && internalValue.length"
43
+ v-if="clearable && selectedItems.length"
44
44
  class="absolute top-1/2 flex -translate-y-1/2 items-center justify-center text-p-gray-40 hover:text-p-gray-60"
45
45
  :class="[SIZES[size], CLEAR_BUTTON_SPACING[size]]"
46
46
  aria-label="Clear selection"
@@ -148,7 +148,18 @@
148
148
  </div>
149
149
  </div>
150
150
  <slot v-if="!computedItems.length" name="no-items">
151
- <div :class="['flex items-center justify-center', SIZES[size]]">No items found</div>
151
+ <div :class="['flex items-center justify-center', SIZES[size]]">
152
+ <template v-if="creatable && search">
153
+ <button
154
+ class="hover:text-primary-hover flex items-center gap-2 font-medium text-p-blue-40"
155
+ @click="handleCreate"
156
+ >
157
+ <PIcon icon="fe:plus-circle" />
158
+ Add '{{ search }}'
159
+ </button>
160
+ </template>
161
+ <template v-else>No items found</template>
162
+ </div>
152
163
  </slot>
153
164
  </div>
154
165
  </div>
@@ -197,7 +208,7 @@ defineSlots<{
197
208
  item(props: { item: any; isItemSelected: boolean; itemTextSplit: string[] }): unknown;
198
209
  }>();
199
210
 
200
- const emit = defineEmits(['update:modelValue', 'select']);
211
+ const emit = defineEmits(['update:modelValue', 'select', 'create']);
201
212
 
202
213
  const props = defineProps({
203
214
  modelValue: {
@@ -241,14 +252,14 @@ const props = defineProps({
241
252
  },
242
253
  },
243
254
  /**
244
- * Set property of **items**’s text value
255
+ * Set property of **items**'s text value
245
256
  */
246
257
  itemText: {
247
258
  type: String,
248
259
  default: 'text',
249
260
  },
250
261
  /**
251
- * Set property of **items**’s value - must be primitive.
262
+ * Set property of **items**'s value - must be primitive.
252
263
  */
253
264
  itemValue: {
254
265
  type: [String, Number],
@@ -328,6 +339,13 @@ const props = defineProps({
328
339
  type: String as PropType<string>,
329
340
  default: 'disabled',
330
341
  },
342
+ /**
343
+ * Enables the ability to create new items when no search results are found
344
+ */
345
+ creatable: {
346
+ type: Boolean,
347
+ default: false,
348
+ },
331
349
  });
332
350
 
333
351
  // Async helpers
@@ -453,4 +471,9 @@ const onHide = () => {
453
471
  destroyNavigationSvc();
454
472
  (formControl.value?.querySelector('button') as HTMLElement)?.focus();
455
473
  };
474
+
475
+ const handleCreate = () => {
476
+ emit('create', search.value);
477
+ dropdownShow.value = false;
478
+ };
456
479
  </script>
@@ -299,20 +299,24 @@ export const useSelectList = (props: Props, inputSearch: InputSearch, virtualize
299
299
  };
300
300
 
301
301
  const clearAll = () => {
302
- if (!props.multiple) return;
303
-
304
302
  search.value = '';
305
303
 
306
304
  // We cannot clear disabled items that are selected
307
- const disabledItemsValues = internalItems.value
308
- .filter((item) => isDisabled(item))
309
- .map((item) => item[props.itemValue]);
305
+ if (props.multiple) {
306
+ const disabledItemsValues = internalItems.value
307
+ .filter((item) => isDisabled(item))
308
+ .map((item) => item[props.itemValue]);
310
309
 
311
- const selectedItemsValues = internalValue.value;
310
+ const selectedItemsValues = internalValue.value;
312
311
 
313
- const selectedDisabledItems = intersection(disabledItemsValues, selectedItemsValues);
312
+ const selectedDisabledItems = intersection(disabledItemsValues, selectedItemsValues);
314
313
 
315
- emit('update:modelValue', toArrOfObjIfNeeded(selectedDisabledItems));
314
+ emit('update:modelValue', toArrOfObjIfNeeded(selectedDisabledItems));
315
+ } else {
316
+ if (!isDisabled(selectedItems.value[0])) {
317
+ emit('update:modelValue', null);
318
+ }
319
+ }
316
320
  };
317
321
 
318
322
  // Watch