design-system-next 2.7.45 → 2.8.1

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,298 @@
1
+ import { ref, toRefs, computed, ComputedRef, onMounted, watch } from 'vue';
2
+ import { onClickOutside, useVModel, useDebounceFn } from '@vueuse/core';
3
+
4
+ import classNames from 'classnames';
5
+
6
+ import type { SetupContext } from 'vue';
7
+ import type { MultiSelectPropTypes, MultiSelectEmitTypes } from './select-multiple';
8
+ import type { MenuListType } from '../../list/list';
9
+
10
+ interface MultiSelectClasses {
11
+ baseClasses: string;
12
+ labelClasses: string;
13
+ }
14
+
15
+ export const useMultiSelect = (props: MultiSelectPropTypes, emit: SetupContext<MultiSelectEmitTypes>['emit']) => {
16
+ const { displayText, menuList, disabled, textField, valueField, disabledLocalSearch } = toRefs(props);
17
+
18
+ const multiSelectClasses: ComputedRef<MultiSelectClasses> = 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
+ const multiSelectRef = ref<HTMLDivElement | null>(null);
32
+ const multiSelectPopperState = ref<boolean>(false);
33
+ const isMultiSelectPopperDisabled = computed(() => disabled.value);
34
+
35
+ const multiSelectModel = useVModel(props, 'modelValue', emit);
36
+ const multiSelectedListItems = ref<MenuListType[]>([]);
37
+ const multiSelectMenuList = ref<MenuListType[]>([]);
38
+ const hasUserSelected = ref(false);
39
+
40
+ const inputText = ref<string | number>('');
41
+ const inputTextBackup = ref<string | number>('');
42
+ const isSearching = ref<boolean>(false);
43
+
44
+ /**
45
+ * Opens the multi-select dropdown menu and resets the searching state.
46
+ */
47
+ const handleMenuToggle = () => {
48
+ multiSelectPopperState.value = true;
49
+ isSearching.value = false;
50
+ };
51
+
52
+ /**
53
+ * Returns the normalized value of the model as an array for internal use.
54
+ */
55
+ const normalizedValue = computed(() => {
56
+ if (Array.isArray(multiSelectModel.value)) {
57
+ return multiSelectModel.value;
58
+ }
59
+
60
+ if (multiSelectModel.value !== undefined && multiSelectModel.value !== null) {
61
+ return [multiSelectModel.value];
62
+ }
63
+
64
+ return [];
65
+ });
66
+
67
+ /**
68
+ * Processes the menuList prop and normalizes it into MenuListType[] for the dropdown.
69
+ */
70
+ const processMenuList = () => {
71
+ if (!menuList.value || !Array.isArray(menuList.value) || menuList.value.length === 0) {
72
+ multiSelectMenuList.value = [];
73
+
74
+ return;
75
+ }
76
+
77
+ const firstItem = menuList.value[0];
78
+
79
+ if (typeof firstItem === 'string') {
80
+ multiSelectMenuList.value = (menuList.value as string[]).map((item) => ({
81
+ text: item,
82
+ value: item,
83
+ }));
84
+
85
+ return;
86
+ }
87
+
88
+ if (typeof firstItem === 'number') {
89
+ multiSelectMenuList.value = (menuList.value as Array<number | string | Record<string, unknown>>)
90
+ .filter((item): item is number => typeof item === 'number')
91
+ .map((item) => ({
92
+ text: item.toString(),
93
+ value: item,
94
+ }));
95
+
96
+ return;
97
+ }
98
+
99
+ if (typeof firstItem === 'object' && firstItem !== null) {
100
+ if ('text' in firstItem && 'value' in firstItem) {
101
+ multiSelectMenuList.value = menuList.value as MenuListType[];
102
+
103
+ return;
104
+ }
105
+
106
+ multiSelectMenuList.value = (menuList.value as Record<string, unknown>[]).map((item) => {
107
+ const displayText = item[textField.value] !== undefined ? String(item[textField.value]) : 'Unnamed';
108
+
109
+ let itemValue = valueField.value && item[valueField.value] !== undefined ? item[valueField.value] : item;
110
+
111
+ if (itemValue === undefined) itemValue = '';
112
+
113
+ return {
114
+ text: displayText,
115
+ value: typeof itemValue === 'object' ? JSON.stringify(itemValue) : String(itemValue),
116
+ _originalObject: item,
117
+ };
118
+ });
119
+
120
+ return;
121
+ }
122
+
123
+ multiSelectMenuList.value = menuList.value as MenuListType[];
124
+ };
125
+
126
+ /**
127
+ * Returns the filtered menu list based on the search input, or the full list if local search is disabled.
128
+ */
129
+ const filteredMultiSelectMenuList = computed(() => {
130
+ if (disabledLocalSearch.value) {
131
+ return multiSelectMenuList.value;
132
+ }
133
+
134
+ const query = inputText.value.toString().toLowerCase().trim();
135
+
136
+ if (!query) return multiSelectMenuList.value;
137
+
138
+ return multiSelectMenuList.value.filter((item) => item.text?.toString().toLowerCase().includes(query));
139
+ });
140
+
141
+ /**
142
+ * Handles the search input and emits the search-string event (debounced).
143
+ */
144
+ const handleSearch = () => {
145
+ isSearching.value = true;
146
+
147
+ debouncedEmitSearch();
148
+ };
149
+
150
+ /**
151
+ * Debounced function to emit the search-string event for remote search.
152
+ */
153
+ const debouncedEmitSearch = useDebounceFn(() => {
154
+ emit('search-string', inputText.value);
155
+ }, 300);
156
+
157
+ /**
158
+ * Handles closing the dropdown when clicking outside, restoring input text if searching.
159
+ */
160
+ onClickOutside(multiSelectRef, () => {
161
+ multiSelectPopperState.value = false;
162
+ if (isSearching.value) {
163
+ inputText.value = inputTextBackup.value;
164
+ }
165
+ isSearching.value = false;
166
+ });
167
+
168
+ /**
169
+ * Handles selection changes from the dropdown and updates the model value.
170
+ * Converts stringified objects back to objects if needed.
171
+ */
172
+ const handleMultiSelectedItem = (multiSelectedItems: MenuListType[]) => {
173
+ const selectedValues = multiSelectedItems.map((item) => {
174
+ if (typeof item.value === 'string' && item.value.startsWith('{') && item.value.endsWith('}')) {
175
+ try {
176
+ return JSON.parse(item.value);
177
+ } catch {
178
+ return item.value;
179
+ }
180
+ }
181
+ return item.value;
182
+ });
183
+
184
+ hasUserSelected.value = true;
185
+ multiSelectModel.value = selectedValues;
186
+ multiSelectPopperState.value = true;
187
+ inputTextBackup.value = inputText.value;
188
+ };
189
+
190
+ /**
191
+ * Updates the selected items in the dropdown based on the current model value.
192
+ * Handles stringified objects and updates the input text accordingly.
193
+ */
194
+ const updateMultiSelectedItemsFromValue = () => {
195
+ if (!multiSelectMenuList.value.length) return;
196
+
197
+ const values = normalizedValue.value;
198
+
199
+ if (!values || !values.length) {
200
+ multiSelectedListItems.value = [];
201
+ inputText.value = '';
202
+ inputTextBackup.value = '';
203
+
204
+ return;
205
+ }
206
+
207
+ multiSelectedListItems.value = multiSelectMenuList.value.filter((item) => {
208
+ return values.some((val) => {
209
+ let itemVal = item.value;
210
+ let valToCompare = val;
211
+
212
+ if (typeof itemVal === 'string' && itemVal.startsWith('{') && itemVal.endsWith('}')) {
213
+ try {
214
+ itemVal = JSON.parse(itemVal);
215
+ } catch {
216
+ // ignore
217
+ }
218
+ }
219
+
220
+ if (typeof valToCompare === 'string' && valToCompare.startsWith('{') && valToCompare.endsWith('}')) {
221
+ try {
222
+ valToCompare = JSON.parse(valToCompare);
223
+ } catch {
224
+ // ignore
225
+ }
226
+ }
227
+
228
+ if (typeof itemVal === 'object' && typeof valToCompare === 'object') {
229
+ return JSON.stringify(itemVal) === JSON.stringify(valToCompare);
230
+ }
231
+
232
+ return itemVal == valToCompare;
233
+ });
234
+ });
235
+
236
+ if (!isSearching.value) {
237
+ if (multiSelectedListItems.value.length > 3) {
238
+ inputText.value = `${multiSelectedListItems.value.length} items selected`;
239
+ } else {
240
+ inputText.value = multiSelectedListItems.value.map((item) => item.text).join(', ');
241
+ }
242
+
243
+ if (displayText.value && !hasUserSelected.value && (!inputText.value || inputText.value === '')) {
244
+ inputText.value = displayText.value;
245
+ inputTextBackup.value = displayText.value;
246
+ } else if (hasUserSelected.value) {
247
+ inputTextBackup.value = inputText.value;
248
+ }
249
+ }
250
+ };
251
+
252
+ /**
253
+ * Clears the selection and input text, and closes the dropdown.
254
+ */
255
+ const handleClear = () => {
256
+ emit('update:modelValue', []);
257
+
258
+ inputText.value = '';
259
+
260
+ multiSelectPopperState.value = false;
261
+ };
262
+
263
+ watch(multiSelectModel, () => {
264
+ updateMultiSelectedItemsFromValue();
265
+ });
266
+
267
+ watch(multiSelectMenuList, () => {
268
+ updateMultiSelectedItemsFromValue();
269
+ });
270
+
271
+ onMounted(() => {
272
+ processMenuList();
273
+
274
+ if (normalizedValue.value.length > 0) {
275
+ updateMultiSelectedItemsFromValue();
276
+ } else if (displayText.value) {
277
+ inputText.value = displayText.value;
278
+ inputTextBackup.value = displayText.value;
279
+ }
280
+ });
281
+
282
+ return {
283
+ multiSelectClasses,
284
+ multiSelectPopperState,
285
+ multiSelectRef,
286
+ multiSelectModel,
287
+ multiSelectMenuList,
288
+ filteredMultiSelectMenuList,
289
+ multiSelectedListItems,
290
+ inputText,
291
+ isMultiSelectPopperDisabled,
292
+ isSearching,
293
+ handleMultiSelectedItem,
294
+ handleSearch,
295
+ handleClear,
296
+ handleMenuToggle,
297
+ };
298
+ };
@@ -0,0 +1,135 @@
1
+ import type { PropType, ExtractPropTypes } from 'vue';
2
+ import type { MenuListType } from '../list/list';
3
+
4
+ export const definePropType = <T>(val: unknown): PropType<T> => val as PropType<T>;
5
+
6
+ const GROUPED_ITEMS_BY_TYPES = ['A-Z', 'Z-A'] as const;
7
+
8
+ const PLACEMENTS_TYPES = [
9
+ 'auto',
10
+ 'auto-start',
11
+ 'auto-end',
12
+ 'top',
13
+ 'top-start',
14
+ 'top-end',
15
+ 'right',
16
+ 'right-start',
17
+ 'right-end',
18
+ 'bottom',
19
+ 'bottom-start',
20
+ 'bottom-end',
21
+ 'left',
22
+ 'left-start',
23
+ 'left-end',
24
+ ] as const;
25
+
26
+ const POPPER_STRATEGY_TYPES = ['fixed', 'absolute'] as const;
27
+
28
+ export const selectPropTypes = {
29
+ id: {
30
+ type: String,
31
+ required: true,
32
+ },
33
+ modelValue: {
34
+ type: [String, Number, Object, Array] as PropType<
35
+ string | number | Record<string, unknown> | (string | number | Record<string, unknown>)[]
36
+ >,
37
+ default: () => [],
38
+ },
39
+ menuList: {
40
+ type: Array as PropType<MenuListType[] | string[] | Record<string, unknown>[]>,
41
+ required: true,
42
+ default: [],
43
+ },
44
+ groupItemsBy: {
45
+ type: String as PropType<(typeof GROUPED_ITEMS_BY_TYPES)[number]>,
46
+ validator: (value: (typeof GROUPED_ITEMS_BY_TYPES)[number] | undefined) => {
47
+ return value === undefined || GROUPED_ITEMS_BY_TYPES.includes(value);
48
+ },
49
+ },
50
+ textField: {
51
+ type: String,
52
+ default: 'text',
53
+ description: 'Field name to use for display text when using dynamic object arrays',
54
+ },
55
+ valueField: {
56
+ type: String,
57
+ default: 'value',
58
+ description: 'Field name to use for value when using dynamic object arrays',
59
+ },
60
+ placeholder: {
61
+ type: String,
62
+ },
63
+ label: {
64
+ type: String,
65
+ default: '',
66
+ },
67
+ placement: {
68
+ type: String as PropType<(typeof PLACEMENTS_TYPES)[number]>,
69
+ validator: (value: (typeof PLACEMENTS_TYPES)[number]) => PLACEMENTS_TYPES.includes(value),
70
+ default: 'bottom',
71
+ },
72
+ popperStrategy: {
73
+ type: String,
74
+ validator: (value: 'fixed' | 'absolute') => POPPER_STRATEGY_TYPES.includes(value),
75
+ default: 'absolute',
76
+ },
77
+ popperWidth: {
78
+ type: String,
79
+ default: '100%',
80
+ },
81
+ width: {
82
+ type: String,
83
+ default: '100%',
84
+ },
85
+ wrapperPosition: {
86
+ type: String,
87
+ default: 'relative',
88
+ },
89
+ displayText: {
90
+ type: String,
91
+ default: '',
92
+ },
93
+ displayHelper: {
94
+ type: Boolean,
95
+ default: false,
96
+ },
97
+ helperIcon: {
98
+ type: String,
99
+ default: null,
100
+ },
101
+ helperText: {
102
+ type: String,
103
+ default: '',
104
+ },
105
+ disabled: {
106
+ type: Boolean,
107
+ default: false,
108
+ },
109
+ clearable: {
110
+ type: Boolean,
111
+ default: false,
112
+ },
113
+ searchable: {
114
+ type: Boolean,
115
+ default: false,
116
+ },
117
+ disabledLocalSearch: {
118
+ type: Boolean,
119
+ default: false,
120
+ },
121
+ };
122
+
123
+ export const selectEmitTypes = {
124
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
125
+ 'update:modelValue': (_value: unknown) => true,
126
+
127
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
128
+ 'infinite-scroll-trigger': (_triggered: boolean) => true,
129
+
130
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
131
+ 'search-string': (_search: string | number) => true,
132
+ };
133
+
134
+ export type SelectPropTypes = ExtractPropTypes<typeof selectPropTypes>;
135
+ export type SelectEmitTypes = typeof selectEmitTypes;
@@ -0,0 +1,168 @@
1
+ <template>
2
+ <div :class="selectClasses.baseClasses">
3
+ <label v-if="props.label" :for="id" :class="selectClasses.labelClasses">
4
+ {{ props.label }}
5
+ </label>
6
+
7
+ <Menu
8
+ :shown="selectPopperState"
9
+ aria-id="select-wrapper"
10
+ distance="4"
11
+ :placement="props.placement"
12
+ :triggers="[]"
13
+ :popper-hide-triggers="[]"
14
+ :auto-hide="false"
15
+ :disabled="isSelectPopperDisabled"
16
+ :container="`#${props.id}`"
17
+ :strategy="
18
+ props.popperStrategy === 'fixed' || props.popperStrategy === 'absolute' ? props.popperStrategy : 'absolute'
19
+ "
20
+ :delay="0"
21
+ :style="{
22
+ position: props.wrapperPosition,
23
+ width: props.width,
24
+ }"
25
+ >
26
+ <div @click="handleMenuToggle">
27
+ <spr-input
28
+ v-model="inputText"
29
+ :class="{
30
+ 'spr-cursor-pointer': !props.searchable,
31
+ }"
32
+ :placeholder="props.placeholder"
33
+ :readonly="!props.searchable"
34
+ :disabled="props.disabled"
35
+ autocomplete="off"
36
+ :helper-text="props.helperText"
37
+ :helper-icon="props.helperIcon"
38
+ :display-helper="props.displayHelper"
39
+ @keyup="handleSearch"
40
+ >
41
+ <template #icon>
42
+ <div class="spr-flex spr-items-center spr-gap-1">
43
+ <Icon
44
+ v-if="props.clearable && inputText"
45
+ class="spr-cursor-pointer"
46
+ icon="ph:x"
47
+ @click.stop="handleClear"
48
+ />
49
+ <Icon icon="ph:caret-down" />
50
+ </div>
51
+ </template>
52
+ </spr-input>
53
+
54
+ <select
55
+ v-if="selectMenuList && selectMenuList.length"
56
+ :value="Array.isArray(selectModel) ? selectModel[0] : selectModel"
57
+ data-testid="qa-hidden-select"
58
+ tabindex="-1"
59
+ aria-hidden="true"
60
+ hidden
61
+ >
62
+ <option v-for="item in selectMenuList" :key="item.value" :value="item.value">
63
+ {{ item.text }}
64
+ </option>
65
+ </select>
66
+ </div>
67
+
68
+ <div
69
+ :id="props.id"
70
+ :style="{
71
+ width: props.popperWidth,
72
+ }"
73
+ ></div>
74
+
75
+ <template #popper>
76
+ <div
77
+ ref="selectRef"
78
+ class="spr-grid spr-max-h-[300px] spr-gap-0.5 spr-overflow-y-auto spr-overflow-x-hidden spr-p-2"
79
+ >
80
+ <template v-if="isSearching">
81
+ <template v-if="!props.disabledLocalSearch">
82
+ <template v-if="filteredSelectMenuList.length > 0">
83
+ <spr-list
84
+ v-model="selectedListItems"
85
+ :menu-list="filteredSelectMenuList"
86
+ :group-items-by="props.groupItemsBy"
87
+ :pre-selected-items="Array.isArray(selectModel) ? selectModel.flat() : [selectModel]"
88
+ @update:model-value="handleSelectedItem"
89
+ />
90
+ </template>
91
+ <template v-else>
92
+ <div class="spr-flex spr-items-center spr-justify-center spr-p-2 spr-text-center">
93
+ <span class="spr-body-sm-regular spr-m-0">No results found</span>
94
+ </div>
95
+ </template>
96
+ </template>
97
+ <template v-else>
98
+ <template v-if="selectMenuList.length > 0">
99
+ <spr-list
100
+ v-model="selectedListItems"
101
+ :menu-list="selectMenuList"
102
+ :group-items-by="props.groupItemsBy"
103
+ :pre-selected-items="Array.isArray(selectModel) ? selectModel.flat() : [selectModel]"
104
+ @update:model-value="handleSelectedItem"
105
+ />
106
+ </template>
107
+ <template v-else>
108
+ <div class="spr-flex spr-items-center spr-justify-center spr-p-2 spr-text-center">
109
+ <span class="spr-body-sm-regular spr-m-0">No results found</span>
110
+ </div>
111
+ </template>
112
+ </template>
113
+ </template>
114
+ <template v-else>
115
+ <template v-if="selectMenuList.length > 0">
116
+ <spr-list
117
+ v-model="selectedListItems"
118
+ :menu-list="selectMenuList"
119
+ :group-items-by="props.groupItemsBy"
120
+ :pre-selected-items="Array.isArray(selectModel) ? selectModel.flat() : [selectModel]"
121
+ @update:model-value="handleSelectedItem"
122
+ />
123
+ </template>
124
+ <template v-else>
125
+ <div class="spr-flex spr-items-center spr-justify-center spr-p-2 spr-text-center">
126
+ <span class="spr-body-sm-regular spr-m-0">No results found</span>
127
+ </div>
128
+ </template>
129
+ </template>
130
+ </div>
131
+ </template>
132
+ </Menu>
133
+ </div>
134
+ </template>
135
+
136
+ <script lang="ts" setup>
137
+ import { Menu } from 'floating-vue';
138
+ import { Icon } from '@iconify/vue';
139
+
140
+ import 'floating-vue/dist/style.css';
141
+
142
+ import SprInput from '../input/input.vue';
143
+ import SprList from '../list/list.vue';
144
+
145
+ import { selectPropTypes, selectEmitTypes } from './select';
146
+
147
+ import { useSelect } from './use-select';
148
+
149
+ const props = defineProps(selectPropTypes);
150
+ const emit = defineEmits(selectEmitTypes);
151
+
152
+ const {
153
+ selectClasses,
154
+ selectPopperState,
155
+ selectRef,
156
+ selectModel,
157
+ selectMenuList,
158
+ filteredSelectMenuList,
159
+ selectedListItems,
160
+ inputText,
161
+ isSelectPopperDisabled,
162
+ isSearching,
163
+ handleSelectedItem,
164
+ handleSearch,
165
+ handleClear,
166
+ handleMenuToggle,
167
+ } = useSelect(props, emit);
168
+ </script>