design-system-next 2.8.0 → 2.8.3

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.
@@ -1,5 +1,5 @@
1
1
  import { ref, toRefs, computed, ComputedRef, onMounted, watch } from 'vue';
2
- import { onClickOutside, useInfiniteScroll, useVModel, useDebounceFn } from '@vueuse/core';
2
+ import { onClickOutside, useVModel } from '@vueuse/core';
3
3
 
4
4
  import classNames from 'classnames';
5
5
 
@@ -13,7 +13,7 @@ interface MultiSelectClasses {
13
13
  }
14
14
 
15
15
  export const useMultiSelect = (props: MultiSelectPropTypes, emit: SetupContext<MultiSelectEmitTypes>['emit']) => {
16
- const { displayText, menuList, disabled, textField, valueField, disabledLocalSearch } = toRefs(props);
16
+ const { displayText, options, disabled, textField, valueField } = toRefs(props);
17
17
 
18
18
  const multiSelectClasses: ComputedRef<MultiSelectClasses> = computed(() => {
19
19
  const baseClasses = classNames('spr-flex spr-flex-col spr-gap-size-spacing-4xs');
@@ -28,57 +28,47 @@ export const useMultiSelect = (props: MultiSelectPropTypes, emit: SetupContext<M
28
28
  };
29
29
  });
30
30
 
31
- // Popper Variables
32
31
  const multiSelectRef = ref<HTMLDivElement | null>(null);
33
32
  const multiSelectPopperState = ref<boolean>(false);
34
33
  const isMultiSelectPopperDisabled = computed(() => disabled.value);
35
34
 
36
- // Multi-Select Variables
37
35
  const multiSelectModel = useVModel(props, 'modelValue', emit);
38
- const multiSelectedListItems = ref<MenuListType[]>();
39
- const multiSelectMenuList = ref<MenuListType[]>([]);
36
+ const multiSelectedListItems = ref<MenuListType[]>([]);
37
+ const multiSelectOptions = ref<MenuListType[]>([]);
40
38
  const hasUserSelected = ref(false);
41
39
 
42
- // Input Text Variables
43
40
  const inputText = ref<string | number>('');
44
41
  const inputTextBackup = ref<string | number>('');
45
- const isSearching = ref<boolean>(false);
46
42
 
47
- const handleMenuToggle = () => {
48
- multiSelectPopperState.value = true;
49
-
50
- isSearching.value = false;
51
- };
52
-
53
- // Normalized value for internal use - always an array
43
+ /**
44
+ * Returns the normalized value of the model as an array for internal use.
45
+ */
54
46
  const normalizedValue = computed(() => {
55
- // If already an array, use it
56
47
  if (Array.isArray(multiSelectModel.value)) {
57
48
  return multiSelectModel.value;
58
49
  }
59
50
 
60
- // If not an array but has a value, make it a single-item array
61
51
  if (multiSelectModel.value !== undefined && multiSelectModel.value !== null) {
62
52
  return [multiSelectModel.value];
63
53
  }
64
54
 
65
- // Default empty array
66
55
  return [];
67
56
  });
68
57
 
69
- const processMenuList = () => {
70
- // Handle empty array or non-array values
71
- if (!menuList.value || !Array.isArray(menuList.value) || menuList.value.length === 0) {
72
- multiSelectMenuList.value = [];
58
+ /**
59
+ * Processes the options prop and normalizes it into MenuListType[] for the multi-select.
60
+ */
61
+ const processOptions = () => {
62
+ if (!options.value || !Array.isArray(options.value) || options.value.length === 0) {
63
+ multiSelectOptions.value = [];
73
64
 
74
65
  return;
75
66
  }
76
67
 
77
- const firstItem = menuList.value[0];
68
+ const firstItem = options.value[0];
78
69
 
79
- // Handle array of strings
80
70
  if (typeof firstItem === 'string') {
81
- multiSelectMenuList.value = (menuList.value as string[]).map((item) => ({
71
+ multiSelectOptions.value = (options.value as string[]).map((item) => ({
82
72
  text: item,
83
73
  value: item,
84
74
  }));
@@ -86,245 +76,147 @@ export const useMultiSelect = (props: MultiSelectPropTypes, emit: SetupContext<M
86
76
  return;
87
77
  }
88
78
 
89
- // Handle array of numbers
90
79
  if (typeof firstItem === 'number') {
91
- multiSelectMenuList.value = (menuList.value as number[]).map((item) => ({
92
- text: item.toString(),
93
- value: item, // Keep the value as a number instead of converting to string
94
- }));
80
+ multiSelectOptions.value = (options.value as Array<number | string | Record<string, unknown>>)
81
+ .filter((item): item is number => typeof item === 'number')
82
+ .map((item) => ({
83
+ text: item.toString(),
84
+ value: item,
85
+ }));
95
86
 
96
87
  return;
97
88
  }
98
89
 
99
- // Handle array of objects with dynamic attributes
100
90
  if (typeof firstItem === 'object' && firstItem !== null) {
101
- // Check if it's already in MenuListType format
102
91
  if ('text' in firstItem && 'value' in firstItem) {
103
- multiSelectMenuList.value = menuList.value as MenuListType[];
92
+ multiSelectOptions.value = options.value as MenuListType[];
93
+
104
94
  return;
105
95
  }
106
- // Transform to MenuListType format using textField and valueField
107
- multiSelectMenuList.value = (menuList.value as Record<string, unknown>[]).map((item) => {
108
- // Ensure displayText is a string
96
+
97
+ multiSelectOptions.value = (options.value as Record<string, unknown>[]).map((item) => {
109
98
  const displayText = item[textField.value] !== undefined ? String(item[textField.value]) : 'Unnamed';
110
- // Use the specified value field if available, otherwise use the entire object
99
+
111
100
  let itemValue = valueField.value && item[valueField.value] !== undefined ? item[valueField.value] : item;
112
- // If itemValue is undefined, fallback to empty string
101
+
113
102
  if (itemValue === undefined) itemValue = '';
103
+
114
104
  return {
115
105
  text: displayText,
116
106
  value: typeof itemValue === 'object' ? JSON.stringify(itemValue) : String(itemValue),
117
- _originalObject: item, // Store the original object for reference
107
+ _originalObject: item,
118
108
  };
119
109
  });
110
+
120
111
  return;
121
112
  }
122
113
 
123
- multiSelectMenuList.value = menuList.value as MenuListType[];
114
+ multiSelectOptions.value = options.value as MenuListType[];
124
115
  };
125
116
 
126
- const filteredMultiSelectMenuList = computed(() => {
127
- if (disabledLocalSearch.value) {
128
- return multiSelectMenuList.value;
129
- }
130
-
131
- const query = inputText.value.toString().toLowerCase().trim();
132
-
133
- if (!query) return multiSelectMenuList.value;
134
-
135
- return multiSelectMenuList.value.filter((item) => item.text?.toString().toLowerCase().includes(query));
136
- });
137
-
138
- watch(menuList, () => {
139
- processMenuList();
140
- });
141
-
142
- // Search handler: always emit search-string, but only filter locally if local search is enabled
143
- const handleSearch = () => {
144
- isSearching.value = true;
145
-
146
- debouncedEmitSearch();
117
+ /**
118
+ * Opens the multi-select options.
119
+ */
120
+ const handleOptionsToggle = () => {
121
+ multiSelectPopperState.value = true;
147
122
  };
148
123
 
149
- const debouncedEmitSearch = useDebounceFn(() => {
150
- emit('search-string', inputText.value);
151
- }, 300);
152
-
153
- onClickOutside(multiSelectRef, () => {
154
- multiSelectPopperState.value = false;
155
- // If user was searching, restore inputText from backup
156
- if (isSearching.value) {
157
- inputText.value = inputTextBackup.value;
158
- }
159
- isSearching.value = false;
160
- });
161
-
162
- useInfiniteScroll(
163
- multiSelectRef,
164
- () => {
165
- emit('infinite-scroll-trigger', true);
166
- },
167
- { distance: 10 },
168
- );
169
-
170
- // Handle multi-selected item for simple list component
124
+ /**
125
+ * Handles selection changes from the multi-select and updates the model value.
126
+ * Converts stringified objects back to objects if needed.
127
+ */
171
128
  const handleMultiSelectedItem = (multiSelectedItems: MenuListType[]) => {
172
- // Get the last clicked item (assuming spr-list emits the full array in order)
173
- const lastClicked = multiSelectedItems[multiSelectedItems.length - 1];
174
- if (!lastClicked) return;
175
-
176
- // Normalize value for comparison
177
- let lastValue: string | number | Record<string, unknown>;
178
- if ('_originalObject' in lastClicked) {
179
- lastValue = lastClicked._originalObject ?? lastClicked.value;
180
- } else if (typeof lastClicked.value === 'number') {
181
- lastValue = lastClicked.value;
182
- } else if (
183
- typeof lastClicked.value === 'string' &&
184
- !isNaN(Number(lastClicked.value)) &&
185
- lastClicked.value.trim() !== '' &&
186
- /^-?\d+(\.\d+)?$/.test(lastClicked.value)
187
- ) {
188
- lastValue = Number(lastClicked.value);
189
- } else {
190
- lastValue = lastClicked.value;
191
- }
192
-
193
- // Always normalize current selection to an array
194
- let current: (string | number | Record<string, unknown>)[] = [];
195
- if (Array.isArray(multiSelectModel.value)) {
196
- current = [...multiSelectModel.value];
197
- } else if (
198
- multiSelectModel.value !== undefined &&
199
- multiSelectModel.value !== null &&
200
- multiSelectModel.value !== ''
201
- ) {
202
- current = [multiSelectModel.value];
203
- }
204
-
205
- // Find if already selected (deep compare for objects, strict for primitives)
206
- const isSelected = current.some((sel) => {
207
- if (typeof sel === 'object' && typeof lastValue === 'object') {
208
- return JSON.stringify(sel) === JSON.stringify(lastValue);
129
+ const selectedValues = multiSelectedItems.map((item) => {
130
+ if (typeof item.value === 'string' && item.value.startsWith('{') && item.value.endsWith('}')) {
131
+ try {
132
+ return JSON.parse(item.value);
133
+ } catch {
134
+ return item.value;
135
+ }
209
136
  }
210
- return sel === lastValue;
211
- });
212
137
 
213
- if (isSelected) {
214
- // Remove from selection
215
- current = current.filter((sel) => {
216
- if (typeof sel === 'object' && typeof lastValue === 'object') {
217
- return JSON.stringify(sel) !== JSON.stringify(lastValue);
218
- }
219
- return sel !== lastValue;
220
- });
221
- } else {
222
- // Add to selection
223
- current.push(lastValue);
224
- }
138
+ return item.value;
139
+ });
225
140
 
226
141
  hasUserSelected.value = true;
227
- multiSelectModel.value = current;
142
+ multiSelectModel.value = selectedValues;
228
143
  multiSelectPopperState.value = true;
229
- // Clone inputText to backup after selection
230
- inputTextBackup.value = inputText.value;
144
+ inputTextBackup.value =
145
+ multiSelectedItems.length > 3
146
+ ? `${multiSelectedItems.length} items selected`
147
+ : multiSelectedItems.map((item) => item.text).join(', ');
148
+
149
+ updateMultiSelectedItemsFromValue();
231
150
  };
232
151
 
233
- // Update multi-selected items when model value changes externally
152
+ /**
153
+ * Updates the selected items in the multi-select based on the current model value.
154
+ * Handles stringified objects and updates the input text accordingly.
155
+ */
234
156
  const updateMultiSelectedItemsFromValue = () => {
235
- if (!multiSelectMenuList.value.length) return;
157
+ if (!multiSelectOptions.value.length) return;
236
158
 
237
159
  const values = normalizedValue.value;
238
160
 
239
161
  if (!values || !values.length) {
240
162
  multiSelectedListItems.value = [];
241
-
242
- // Always clear inputText and backup if nothing is selected
243
163
  inputText.value = '';
244
164
  inputTextBackup.value = '';
245
165
 
246
166
  return;
247
167
  }
248
168
 
249
- // Store both original values and string versions for flexible matching
250
- const valueData = values.map((val) => {
251
- if (val === undefined || val === null) return { original: '', string: '' };
252
-
253
- // For objects, use JSON string representation
254
- if (typeof val === 'object') {
255
- return {
256
- original: val,
257
- string: JSON.stringify(val),
258
- isObject: true,
259
- id: 'id' in val ? val.id : undefined,
260
- };
261
- }
262
-
263
- // For numbers and strings, keep original and string versions
264
- return {
265
- original: val,
266
- string: val.toString(),
267
- isObject: false,
268
- };
269
- });
270
-
271
- // Extract just string values for comparison
272
- const valueStrings = valueData.map((v) => v.string);
273
-
274
- multiSelectedListItems.value = multiSelectMenuList.value.filter((item) => {
275
- // Handle objects with _originalObject property
276
- if ('_originalObject' in item && item._originalObject) {
277
- return valueData.some((v) => {
278
- // If both are objects, compare by JSON string or by ID
279
- if (v.isObject && typeof v.original === 'object') {
280
- const originalObj = item._originalObject as Record<string, unknown>;
281
-
282
- if (v.original === originalObj) return true;
169
+ multiSelectedListItems.value = multiSelectOptions.value.filter((item) => {
170
+ return values.some((val) => {
171
+ let itemVal = item.value;
172
+ let valToCompare = val;
283
173
 
284
- const itemJson = JSON.stringify(originalObj);
285
-
286
- if (v.string === itemJson) return true;
174
+ if (typeof itemVal === 'string' && itemVal.startsWith('{') && itemVal.endsWith('}')) {
175
+ try {
176
+ itemVal = JSON.parse(itemVal);
177
+ } catch {
178
+ // ignore
179
+ }
180
+ }
287
181
 
288
- if (v.id !== undefined && 'id' in originalObj) {
289
- return v.id === originalObj.id;
290
- }
182
+ if (typeof valToCompare === 'string' && valToCompare.startsWith('{') && valToCompare.endsWith('}')) {
183
+ try {
184
+ valToCompare = JSON.parse(valToCompare);
185
+ } catch {
186
+ // ignore
291
187
  }
292
- return false;
293
- });
294
- }
188
+ }
295
189
 
296
- // Handle both numeric and string values correctly
297
- if (typeof item.value === 'number') {
298
- return valueData.some((v) => v.original === item.value || v.string === String(item.value));
299
- } else {
300
- return valueStrings.includes(String(item.value));
301
- }
190
+ if (typeof itemVal === 'object' && typeof valToCompare === 'object') {
191
+ return JSON.stringify(itemVal) === JSON.stringify(valToCompare);
192
+ }
193
+
194
+ return itemVal == valToCompare;
195
+ });
302
196
  });
303
197
 
304
- // Only update inputText if not searching
305
- if (!isSearching.value) {
306
- if (multiSelectedListItems.value.length > 3) {
307
- inputText.value = `${multiSelectedListItems.value.length} items selected`;
308
- } else {
309
- inputText.value = multiSelectedListItems.value.map((item) => item.text).join(', ');
310
- }
198
+ if (multiSelectedListItems.value.length > 3) {
199
+ inputText.value = `${multiSelectedListItems.value.length} items selected`;
200
+ } else {
201
+ inputText.value = multiSelectedListItems.value.map((item) => item.text).join(', ');
202
+ }
311
203
 
312
- // Only use displayText.value if user hasn't selected anything yet
313
- if (displayText.value && !hasUserSelected.value && (!inputText.value || inputText.value === '')) {
314
- inputText.value = displayText.value;
315
- inputTextBackup.value = displayText.value;
316
- } else {
317
- // Always update backup to match inputText if not searching
318
- inputTextBackup.value = inputText.value;
319
- }
204
+ // Always update backup to match inputText after update
205
+ inputTextBackup.value = inputText.value;
206
+
207
+ if (displayText.value && !hasUserSelected.value && (!inputText.value || inputText.value === '')) {
208
+ inputText.value = displayText.value;
209
+ inputTextBackup.value = displayText.value;
320
210
  }
321
211
  };
322
212
 
213
+ /**
214
+ * Clears the selection and input text, and closes the multi-select.
215
+ */
323
216
  const handleClear = () => {
324
- emit('update:modelValue', '');
217
+ emit('update:modelValue', []);
325
218
 
326
219
  inputText.value = '';
327
-
328
220
  multiSelectPopperState.value = false;
329
221
  };
330
222
 
@@ -332,14 +224,22 @@ export const useMultiSelect = (props: MultiSelectPropTypes, emit: SetupContext<M
332
224
  updateMultiSelectedItemsFromValue();
333
225
  });
334
226
 
335
- watch(multiSelectMenuList, () => {
227
+ watch(multiSelectOptions, () => {
228
+ updateMultiSelectedItemsFromValue();
229
+ });
230
+
231
+ /**
232
+ * Handles closing the multi-select when clicking outside.
233
+ */
234
+ onClickOutside(multiSelectRef, () => {
235
+ multiSelectPopperState.value = false;
236
+
336
237
  updateMultiSelectedItemsFromValue();
337
238
  });
338
239
 
339
240
  onMounted(() => {
340
- processMenuList();
241
+ processOptions();
341
242
 
342
- // Set initial multi-selected items based on model value
343
243
  if (normalizedValue.value.length > 0) {
344
244
  updateMultiSelectedItemsFromValue();
345
245
  } else if (displayText.value) {
@@ -353,15 +253,12 @@ export const useMultiSelect = (props: MultiSelectPropTypes, emit: SetupContext<M
353
253
  multiSelectPopperState,
354
254
  multiSelectRef,
355
255
  multiSelectModel,
356
- multiSelectMenuList,
357
- filteredMultiSelectMenuList,
256
+ multiSelectOptions,
358
257
  multiSelectedListItems,
359
258
  inputText,
360
259
  isMultiSelectPopperDisabled,
361
- isSearching,
362
260
  handleMultiSelectedItem,
363
- handleSearch,
364
261
  handleClear,
365
- handleMenuToggle,
262
+ handleOptionsToggle,
366
263
  };
367
264
  };
@@ -34,9 +34,9 @@ export const selectPropTypes = {
34
34
  type: [String, Number, Object, Array] as PropType<
35
35
  string | number | Record<string, unknown> | (string | number | Record<string, unknown>)[]
36
36
  >,
37
- default: () => [],
37
+ default: '',
38
38
  },
39
- menuList: {
39
+ options: {
40
40
  type: Array as PropType<MenuListType[] | string[] | Record<string, unknown>[]>,
41
41
  required: true,
42
42
  default: [],
@@ -23,7 +23,7 @@
23
23
  width: props.width,
24
24
  }"
25
25
  >
26
- <div @click="handleMenuToggle">
26
+ <div @click="handleOptionsToggle">
27
27
  <spr-input
28
28
  v-model="inputText"
29
29
  :class="{
@@ -52,14 +52,14 @@
52
52
  </spr-input>
53
53
 
54
54
  <select
55
- v-if="selectMenuList && selectMenuList.length"
55
+ v-if="selectOptions && selectOptions.length"
56
56
  :value="Array.isArray(selectModel) ? selectModel[0] : selectModel"
57
57
  data-testid="qa-hidden-select"
58
58
  tabindex="-1"
59
59
  aria-hidden="true"
60
60
  hidden
61
61
  >
62
- <option v-for="item in selectMenuList" :key="item.value" :value="item.value">
62
+ <option v-for="item in selectOptions" :key="item.value" :value="item.value">
63
63
  {{ item.text }}
64
64
  </option>
65
65
  </select>
@@ -79,10 +79,10 @@
79
79
  >
80
80
  <template v-if="isSearching">
81
81
  <template v-if="!props.disabledLocalSearch">
82
- <template v-if="filteredSelectMenuList.length > 0">
82
+ <template v-if="filteredSelectOptions.length > 0">
83
83
  <spr-list
84
84
  v-model="selectedListItems"
85
- :menu-list="filteredSelectMenuList"
85
+ :menu-list="filteredSelectOptions"
86
86
  :group-items-by="props.groupItemsBy"
87
87
  :pre-selected-items="Array.isArray(selectModel) ? selectModel.flat() : [selectModel]"
88
88
  @update:model-value="handleSelectedItem"
@@ -95,10 +95,10 @@
95
95
  </template>
96
96
  </template>
97
97
  <template v-else>
98
- <template v-if="selectMenuList.length > 0">
98
+ <template v-if="selectOptions.length > 0">
99
99
  <spr-list
100
100
  v-model="selectedListItems"
101
- :menu-list="selectMenuList"
101
+ :menu-list="selectOptions"
102
102
  :group-items-by="props.groupItemsBy"
103
103
  :pre-selected-items="Array.isArray(selectModel) ? selectModel.flat() : [selectModel]"
104
104
  @update:model-value="handleSelectedItem"
@@ -112,10 +112,10 @@
112
112
  </template>
113
113
  </template>
114
114
  <template v-else>
115
- <template v-if="selectMenuList.length > 0">
115
+ <template v-if="selectOptions.length > 0">
116
116
  <spr-list
117
117
  v-model="selectedListItems"
118
- :menu-list="selectMenuList"
118
+ :menu-list="selectOptions"
119
119
  :group-items-by="props.groupItemsBy"
120
120
  :pre-selected-items="Array.isArray(selectModel) ? selectModel.flat() : [selectModel]"
121
121
  @update:model-value="handleSelectedItem"
@@ -154,8 +154,8 @@ const {
154
154
  selectPopperState,
155
155
  selectRef,
156
156
  selectModel,
157
- selectMenuList,
158
- filteredSelectMenuList,
157
+ selectOptions,
158
+ filteredSelectOptions,
159
159
  selectedListItems,
160
160
  inputText,
161
161
  isSelectPopperDisabled,
@@ -163,6 +163,6 @@ const {
163
163
  handleSelectedItem,
164
164
  handleSearch,
165
165
  handleClear,
166
- handleMenuToggle,
166
+ handleOptionsToggle,
167
167
  } = useSelect(props, emit);
168
168
  </script>