design-system-next 2.7.45 → 2.8.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.
@@ -0,0 +1,362 @@
1
+ import { ref, toRefs, computed, ComputedRef, onMounted, watch } from 'vue';
2
+ import { onClickOutside, useInfiniteScroll, useVModel, useDebounceFn } from '@vueuse/core';
3
+
4
+ import classNames from 'classnames';
5
+
6
+ import type { SetupContext } from 'vue';
7
+ import type { SelectPropTypes, SelectEmitTypes } from './select';
8
+ import type { MenuListType } from '../list/list';
9
+
10
+ interface SelectClasses {
11
+ baseClasses: string;
12
+ labelClasses: string;
13
+ }
14
+
15
+ export const useSelect = (props: SelectPropTypes, emit: SetupContext<SelectEmitTypes>['emit']) => {
16
+ const { displayText, menuList, disabled, textField, valueField, disabledLocalSearch } = toRefs(props);
17
+
18
+ const selectClasses: ComputedRef<SelectClasses> = computed(() => {
19
+ const baseClasses = classNames('spr-flex spr-flex-col spr-gap-size-spacing-4xs');
20
+
21
+ const labelClasses = classNames('spr-body-sm-regular spr-text-color-strong spr-block', {
22
+ 'spr-text-color-on-fill-disabled': disabled.value,
23
+ });
24
+
25
+ return {
26
+ baseClasses,
27
+ labelClasses,
28
+ };
29
+ });
30
+
31
+ // Popper Variables
32
+ const selectRef = ref<HTMLDivElement | null>(null);
33
+ const selectPopperState = ref<boolean>(false);
34
+ const isSelectPopperDisabled = computed(() => disabled.value);
35
+
36
+ // Select Variables
37
+ const selectModel = useVModel(props, 'modelValue', emit);
38
+ const selectedListItems = ref<MenuListType[]>();
39
+ const selectMenuList = ref<MenuListType[]>([]);
40
+ const hasUserSelected = ref(false);
41
+
42
+ // Input Text Variables
43
+ const inputText = ref<string | number>('');
44
+ const inputTextBackup = ref<string | number>('');
45
+ const isSearching = ref<boolean>(false);
46
+
47
+ const handleMenuToggle = () => {
48
+ selectPopperState.value = true;
49
+
50
+ isSearching.value = false;
51
+ };
52
+
53
+ // Normalized value for internal use - always an array
54
+ const normalizedValue = computed(() => {
55
+ // If already an array, use it
56
+ if (Array.isArray(selectModel.value)) {
57
+ return selectModel.value;
58
+ }
59
+
60
+ // If not an array but has a value, make it a single-item array
61
+ if (selectModel.value !== undefined && selectModel.value !== null) {
62
+ return [selectModel.value];
63
+ }
64
+
65
+ // Default empty array
66
+ return [];
67
+ });
68
+
69
+ // Compatibility layer for pre-selected items (List component expects string[] format)
70
+ const compatPreSelectedItems = computed(() => {
71
+ if (selectModel.value === undefined || selectModel.value === null) return [];
72
+
73
+ if (Array.isArray(selectModel.value)) {
74
+ return selectModel.value;
75
+ }
76
+
77
+ return [selectModel.value];
78
+ });
79
+
80
+ const processMenuList = () => {
81
+ // Handle empty array or non-array values
82
+ if (!menuList.value || !Array.isArray(menuList.value) || menuList.value.length === 0) {
83
+ selectMenuList.value = [];
84
+
85
+ return;
86
+ }
87
+
88
+ const firstItem = menuList.value[0];
89
+
90
+ // Handle array of strings
91
+ if (typeof firstItem === 'string') {
92
+ selectMenuList.value = (menuList.value as string[]).map((item) => ({
93
+ text: item,
94
+ value: item,
95
+ }));
96
+
97
+ return;
98
+ }
99
+
100
+ // Handle array of numbers
101
+ if (typeof firstItem === 'number') {
102
+ selectMenuList.value = (menuList.value as number[]).map((item) => ({
103
+ text: item.toString(),
104
+ value: item, // Keep the value as a number instead of converting to string
105
+ }));
106
+
107
+ return;
108
+ }
109
+
110
+ // Handle array of objects with dynamic attributes
111
+ if (typeof firstItem === 'object' && firstItem !== null) {
112
+ // Check if it's already in MenuListType format
113
+ if ('text' in firstItem && 'value' in firstItem) {
114
+ selectMenuList.value = menuList.value as MenuListType[];
115
+
116
+ return;
117
+ }
118
+
119
+ // Transform to MenuListType format using textField and valueField
120
+ selectMenuList.value = (menuList.value as Record<string, unknown>[]).map((item) => {
121
+ const displayText = item[textField.value] || 'Unnamed';
122
+ // Use the specified value field if available, otherwise use the entire object
123
+ const itemValue = valueField.value && item[valueField.value] !== undefined ? item[valueField.value] : item;
124
+
125
+ return {
126
+ text: displayText,
127
+ value: typeof itemValue === 'object' ? JSON.stringify(itemValue) : itemValue.toString(),
128
+ _originalObject: item, // Store the original object for reference
129
+ };
130
+ });
131
+
132
+ return;
133
+ }
134
+
135
+ selectMenuList.value = menuList.value as MenuListType[];
136
+ };
137
+
138
+ const filteredSelectMenuList = computed(() => {
139
+ if (disabledLocalSearch.value) {
140
+ return selectMenuList.value;
141
+ }
142
+
143
+ const query = inputText.value.toString().toLowerCase().trim();
144
+
145
+ if (!query) return selectMenuList.value;
146
+
147
+ return selectMenuList.value.filter((item) => item.text?.toString().toLowerCase().includes(query));
148
+ });
149
+
150
+ watch(menuList, () => {
151
+ processMenuList();
152
+ });
153
+
154
+ // Search handler: always emit search-string, but only filter locally if local search is enabled
155
+ const handleSearch = () => {
156
+ isSearching.value = true;
157
+
158
+ debouncedEmitSearch();
159
+ };
160
+
161
+ const debouncedEmitSearch = useDebounceFn(() => {
162
+ emit('search-string', inputText.value);
163
+ }, 300);
164
+
165
+ onClickOutside(selectRef, () => {
166
+ selectPopperState.value = false;
167
+
168
+ // If user was searching, restore inputText from backup
169
+ if (isSearching.value) {
170
+ inputText.value = inputTextBackup.value;
171
+ }
172
+
173
+ isSearching.value = false;
174
+ });
175
+
176
+ useInfiniteScroll(
177
+ selectRef,
178
+ () => {
179
+ emit('infinite-scroll-trigger', true);
180
+ },
181
+ { distance: 10 },
182
+ );
183
+
184
+ // Handle selected item for simple list component
185
+ const handleSelectedItem = (selectedItems: MenuListType[]) => {
186
+ if (selectedItems.length === 0) {
187
+ selectModel.value = '';
188
+
189
+ return;
190
+ }
191
+
192
+ hasUserSelected.value = true; // User has now made a selection
193
+
194
+ const item = selectedItems[0];
195
+
196
+ // If we stored the original object, use it
197
+ if ('_originalObject' in item) {
198
+ selectModel.value = item._originalObject as Record<string, unknown>;
199
+ } else {
200
+ // For simple types, return the value (try to convert number strings to numbers)
201
+ const itemValue = item.value;
202
+ const itemText = item.text || '';
203
+
204
+ if (typeof itemValue === 'string' && !isNaN(Number(itemValue)) && itemValue.trim() !== '') {
205
+ selectModel.value = Number(itemValue);
206
+ } else {
207
+ selectModel.value = itemValue;
208
+ }
209
+
210
+ inputText.value = itemText;
211
+ }
212
+
213
+ // Clone inputText to backup after selection
214
+ inputTextBackup.value = inputText.value;
215
+
216
+ // Always close select for single selection
217
+ setTimeout(() => {
218
+ selectPopperState.value = false;
219
+ }, 10);
220
+ };
221
+
222
+ // Update selected items when model value changes externally
223
+ const updateSelectedItemsFromValue = () => {
224
+ if (!selectMenuList.value.length) return;
225
+
226
+ const values = normalizedValue.value;
227
+
228
+ if (!values || !values.length) {
229
+ selectedListItems.value = [];
230
+
231
+ // Only set displayText if user hasn't typed anything
232
+ if (
233
+ displayText.value &&
234
+ !hasUserSelected.value &&
235
+ !isSearching.value &&
236
+ (!inputText.value || inputText.value === '')
237
+ ) {
238
+ inputText.value = displayText.value;
239
+
240
+ // Clone displayText to backup if present
241
+ inputTextBackup.value = displayText.value;
242
+ } else if (!hasUserSelected.value && (!inputText.value || inputText.value === '')) {
243
+ inputText.value = '';
244
+ inputTextBackup.value = '';
245
+ }
246
+
247
+ return;
248
+ }
249
+
250
+ // Store both original values and string versions for flexible matching
251
+ const valueData = values.map((val) => {
252
+ if (val === undefined || val === null) return { original: '', string: '' };
253
+
254
+ // For objects, use JSON string representation
255
+ if (typeof val === 'object') {
256
+ return {
257
+ original: val,
258
+ string: JSON.stringify(val),
259
+ isObject: true,
260
+ id: 'id' in val ? val.id : undefined,
261
+ };
262
+ }
263
+
264
+ // For numbers and strings, keep original and string versions
265
+ return {
266
+ original: val,
267
+ string: val.toString(),
268
+ isObject: false,
269
+ };
270
+ });
271
+
272
+ // Extract just string values for comparison
273
+ const valueStrings = valueData.map((v) => v.string);
274
+
275
+ selectedListItems.value = selectMenuList.value.filter((item) => {
276
+ // Handle objects with _originalObject property
277
+ if ('_originalObject' in item && item._originalObject) {
278
+ return valueData.some((v) => {
279
+ // If both are objects, compare by JSON string or by ID
280
+ if (v.isObject && typeof v.original === 'object') {
281
+ const originalObj = item._originalObject as Record<string, unknown>;
282
+
283
+ if (v.original === originalObj) return true;
284
+
285
+ const itemJson = JSON.stringify(originalObj);
286
+
287
+ if (v.string === itemJson) return true;
288
+
289
+ if (v.id !== undefined && 'id' in originalObj) {
290
+ return v.id === originalObj.id;
291
+ }
292
+ }
293
+ return false;
294
+ });
295
+ }
296
+
297
+ // Handle both numeric and string values correctly
298
+ if (typeof item.value === 'number') {
299
+ return valueData.some((v) => v.original === item.value || v.string === String(item.value));
300
+ } else {
301
+ return valueStrings.includes(String(item.value));
302
+ }
303
+ });
304
+
305
+ // Only update inputText if not searching
306
+ if (!isSearching.value) {
307
+ inputText.value = selectedListItems.value.map((item) => item.text).join(', ');
308
+
309
+ // Only use displayText.value if user hasn't selected anything yet
310
+ if (displayText.value && !hasUserSelected.value && (!inputText.value || inputText.value === '')) {
311
+ inputText.value = displayText.value;
312
+ inputTextBackup.value = displayText.value;
313
+ } else {
314
+ // Always update backup to match inputText if not searching
315
+ inputTextBackup.value = inputText.value;
316
+ }
317
+ }
318
+ };
319
+
320
+ const handleClear = () => {
321
+ emit('update:modelValue', '');
322
+
323
+ inputText.value = '';
324
+ };
325
+
326
+ watch(selectModel, () => {
327
+ updateSelectedItemsFromValue();
328
+ });
329
+
330
+ watch(selectMenuList, () => {
331
+ updateSelectedItemsFromValue();
332
+ });
333
+
334
+ onMounted(() => {
335
+ processMenuList();
336
+
337
+ // Set initial selected items based on model value
338
+ if (normalizedValue.value.length > 0) {
339
+ updateSelectedItemsFromValue();
340
+ } else if (displayText.value) {
341
+ inputText.value = displayText.value;
342
+ inputTextBackup.value = displayText.value;
343
+ }
344
+ });
345
+
346
+ return {
347
+ selectClasses,
348
+ selectPopperState,
349
+ handleMenuToggle,
350
+ selectRef,
351
+ selectModel: compatPreSelectedItems, // Use compatible format for lists
352
+ selectMenuList,
353
+ filteredSelectMenuList,
354
+ selectedListItems,
355
+ inputText,
356
+ isSelectPopperDisabled,
357
+ isSearching,
358
+ handleSelectedItem,
359
+ handleSearch,
360
+ handleClear,
361
+ };
362
+ };