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.
@@ -1,5 +1,16 @@
1
1
  <template>
2
2
  <div class="spr-font-main">
3
+ <div v-if="props.searchableMenu" class="spr-mb-3 spr-grid spr-gap-3">
4
+ <spr-input
5
+ v-model="searchText"
6
+ :placeholder="props.searchableMenuPlaceholder"
7
+ autocomplete="off"
8
+ @keyup="handleSearch"
9
+ />
10
+
11
+ <div v-if="isParentMenu" class="spr-background-color-surface spr-h-[1px]"></div>
12
+ </div>
13
+
3
14
  <template v-if="props.groupItemsBy">
4
15
  <div class="spr-grid spr-gap-2">
5
16
  <div v-for="(list, listIndex) in groupedMenuList" :key="listIndex" class="spr-grid spr-gap-0.5">
@@ -46,11 +57,21 @@
46
57
  @click="handleSelectedItem(item)"
47
58
  >
48
59
  <spr-checkbox v-if="props.multiSelect" :disabled="item.disabled" :checked="isItemSelected(item)" />
49
- <div :class="['spr-flex spr-flex-auto spr-flex-col spr-justify-start', { 'spr-text-color-disabled': item.disabled }]">
60
+ <div
61
+ :class="[
62
+ 'spr-flex spr-flex-auto spr-flex-col spr-justify-start',
63
+ { 'spr-text-color-disabled': item.disabled },
64
+ ]"
65
+ >
50
66
  <span class="spr-text-left spr-text-xs">{{ item.text }}</span>
51
- <span v-if="item.subtext" :class="['spr-body-xs-regular spr-text-color-base spr-text-left', { 'spr-text-color-disabled': item.disabled }]">{{
52
- item.subtext
53
- }}</span>
67
+ <span
68
+ v-if="item.subtext"
69
+ :class="[
70
+ 'spr-body-xs-regular spr-text-color-base spr-text-left',
71
+ { 'spr-text-color-disabled': item.disabled },
72
+ ]"
73
+ >{{ item.subtext }}</span
74
+ >
54
75
  </div>
55
76
  <Icon
56
77
  v-if="isItemSelected(item) && !props.multiSelect"
@@ -73,13 +94,20 @@ import { Icon } from '@iconify/vue';
73
94
  import { listPropTypes, listEmitTypes } from './list';
74
95
  import { useList } from './use-list';
75
96
 
76
- import SprCheckbox from '../checkbox/checkbox.vue';
97
+ import SprCheckbox from '@/components/checkbox/checkbox.vue';
98
+ import SprInput from '@/components/input/input.vue';
77
99
 
78
100
  const props = defineProps(listPropTypes);
79
101
  const emit = defineEmits(listEmitTypes);
80
102
 
81
- const { localizedMenuList, groupedMenuList, isItemSelected, getListItemClasses, handleSelectedItem } = useList(
82
- props,
83
- emit,
84
- );
103
+ const {
104
+ searchText,
105
+ localizedMenuList,
106
+ groupedMenuList,
107
+ isParentMenu,
108
+ isItemSelected,
109
+ getListItemClasses,
110
+ handleSearch,
111
+ handleSelectedItem,
112
+ } = useList(props, emit);
85
113
  </script>
@@ -13,7 +13,7 @@ interface ListClasses {
13
13
  export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>['emit']) => {
14
14
  const selectedItems = useVModel(props, 'modelValue', emit);
15
15
 
16
- const { menuList, groupItemsBy, multiSelect, preSelectedItems } = toRefs(props);
16
+ const { menuList, menuLevel, groupItemsBy, multiSelect, preSelectedItems } = toRefs(props);
17
17
 
18
18
  const listClasses: ComputedRef<ListClasses> = computed(() => {
19
19
  const listItemClasses = classNames(
@@ -26,6 +26,8 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
26
26
  return { listItemClasses };
27
27
  });
28
28
 
29
+ const searchText = ref<string>('');
30
+
29
31
  const localizedMenuList = ref<MenuListType[]>([]);
30
32
  const groupedMenuList = ref<GroupedMenuListType[]>([
31
33
  {
@@ -35,66 +37,72 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
35
37
  ]);
36
38
 
37
39
  // #region - Helper Methods
40
+ const isParentMenu = computed(() => menuLevel.value === 0);
41
+
38
42
  const isItemSelected = (item: MenuListType) => {
39
43
  // First check standard selection via the selectedItems array
40
44
  const directSelected = selectedItems.value.some((selectedItem) => {
41
45
  // Compare both text and value properties to handle different value types
42
46
  if (selectedItem.text === item.text) return true;
43
-
47
+
44
48
  // Ensure comparison works for both string and number values
45
49
  const selectedItemValue = selectedItem.value;
46
50
  const itemValue = item.value;
47
-
51
+
48
52
  // For primitives, use string comparison to handle number-string comparison properly
49
53
  if (typeof selectedItemValue !== 'object' && typeof itemValue !== 'object') {
50
54
  return String(selectedItemValue) === String(itemValue);
51
55
  }
52
-
56
+
53
57
  // For objects, use JSON.stringify for comparison (will match for equality)
54
- if (typeof selectedItemValue === 'object' && selectedItemValue !== null &&
55
- typeof itemValue === 'object' && itemValue !== null) {
58
+ if (
59
+ typeof selectedItemValue === 'object' &&
60
+ selectedItemValue !== null &&
61
+ typeof itemValue === 'object' &&
62
+ itemValue !== null
63
+ ) {
56
64
  return JSON.stringify(selectedItemValue) === JSON.stringify(itemValue);
57
65
  }
58
-
66
+
59
67
  return false;
60
68
  });
61
-
69
+
62
70
  if (directSelected) return true;
63
-
71
+
64
72
  // Additional check for objects stored in _originalObject property
65
73
  if ('_originalObject' in item && item._originalObject && preSelectedItems.value?.length) {
66
- return preSelectedItems.value.some(preSelectedValue => {
74
+ return preSelectedItems.value.some((preSelectedValue) => {
67
75
  // Direct reference comparison (most accurate)
68
76
  if (preSelectedValue === item._originalObject) {
69
77
  return true;
70
78
  }
71
-
79
+
72
80
  // If both are objects, compare their serialized forms
73
81
  if (typeof preSelectedValue === 'object' && preSelectedValue !== null) {
74
82
  const originalObj = item._originalObject as Record<string, unknown>;
75
-
83
+
76
84
  if (typeof originalObj === 'object') {
77
85
  // First try comparing by ID for more reliable object comparison
78
86
  if ('id' in preSelectedValue && 'id' in originalObj) {
79
87
  return preSelectedValue.id === originalObj.id;
80
88
  }
81
-
89
+
82
90
  // Fallback to full object comparison
83
91
  const valString = JSON.stringify(preSelectedValue);
84
92
  const itemString = JSON.stringify(originalObj);
85
93
  return valString === itemString;
86
94
  }
87
-
95
+
88
96
  // If object has an id field, check if it matches with the item value
89
97
  if ('id' in preSelectedValue) {
90
98
  return String(item.value).includes(String(preSelectedValue.id));
91
99
  }
92
100
  }
93
-
101
+
94
102
  return false;
95
103
  });
96
104
  }
97
-
105
+
98
106
  return false;
99
107
  };
100
108
 
@@ -159,52 +167,48 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
159
167
  // For objects, check for matching _originalObject properties
160
168
  if (typeof preSelectedItem === 'object' && preSelectedItem !== null) {
161
169
  // Try to find an item with a matching _originalObject
162
- const objectMatch = localizedMenuList.value.find(menuItem => {
170
+ const objectMatch = localizedMenuList.value.find((menuItem) => {
163
171
  if (!menuItem._originalObject) return false;
164
-
172
+
165
173
  // Compare serialized versions for deep equality
166
174
  return JSON.stringify(menuItem._originalObject) === JSON.stringify(preSelectedItem);
167
175
  });
168
-
176
+
169
177
  if (objectMatch) return objectMatch;
170
-
178
+
171
179
  // If no direct object match, try matching on ID if both have it
172
180
  if ('id' in preSelectedItem) {
173
- const idMatch = localizedMenuList.value.find(menuItem => {
181
+ const idMatch = localizedMenuList.value.find((menuItem) => {
174
182
  if (menuItem._originalObject && 'id' in menuItem._originalObject) {
175
183
  return menuItem._originalObject.id === preSelectedItem.id;
176
184
  }
177
185
  // Also check if the value field contains a stringified version that includes the id
178
186
  return String(menuItem.value).includes(String(preSelectedItem.id));
179
187
  });
180
-
188
+
181
189
  if (idMatch) return idMatch;
182
190
  }
183
191
  }
184
-
192
+
185
193
  // First try direct value comparison (for exact matches)
186
- const directMatch = localizedMenuList.value.find(
187
- (menuItem) => menuItem.value === preSelectedItem
188
- );
194
+ const directMatch = localizedMenuList.value.find((menuItem) => menuItem.value === preSelectedItem);
189
195
  if (directMatch) return directMatch;
190
-
196
+
191
197
  // Special handling for number values in the preSelectedItems array
192
198
  if (typeof preSelectedItem === 'number') {
193
199
  // Find items that match the number value either directly or as a string
194
200
  const numericMatch = localizedMenuList.value.find(
195
- (menuItem) =>
201
+ (menuItem) =>
196
202
  // Match if menuItem.value is the same number
197
203
  (typeof menuItem.value === 'number' && menuItem.value === preSelectedItem) ||
198
204
  // Match if menuItem.value is a string representation of the number
199
- (typeof menuItem.value === 'string' && menuItem.value === String(preSelectedItem))
205
+ (typeof menuItem.value === 'string' && menuItem.value === String(preSelectedItem)),
200
206
  );
201
207
  if (numericMatch) return numericMatch;
202
208
  }
203
-
209
+
204
210
  // Then try string comparison for cases where types differ (string vs number)
205
- return localizedMenuList.value.find(
206
- (menuItem) => String(menuItem.value) === String(preSelectedItem)
207
- );
211
+ return localizedMenuList.value.find((menuItem) => String(menuItem.value) === String(preSelectedItem));
208
212
  })
209
213
  .filter(Boolean) as MenuListType[];
210
214
 
@@ -229,50 +233,112 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
229
233
  const getListItemClasses = (item: MenuListType) => ({
230
234
  [listClasses.value.listItemClasses]: !item.disabled,
231
235
  'spr-background-color-single-active': isItemSelected(item) && !item.disabled,
232
- 'hover:spr-cursor-not-allowed spr-flex spr-cursor-pointer spr-items-center spr-gap-1.5 spr-rounded-lg spr-p-2': item.disabled,
236
+ 'hover:spr-cursor-not-allowed spr-flex spr-cursor-pointer spr-items-center spr-gap-1.5 spr-rounded-lg spr-p-2':
237
+ item.disabled,
233
238
  });
234
239
 
240
+ const handleSearch = () => {
241
+ const search = searchText.value.trim().toLowerCase();
242
+
243
+ if (!search) {
244
+ setMenuList();
245
+
246
+ return;
247
+ }
248
+
249
+ // Filter items by text or subtext
250
+ const filtered = props.menuList.filter((item) => {
251
+ const textMatch = item.text.toLowerCase().includes(search);
252
+ const subtextMatch = item.subtext ? item.subtext.toLowerCase().includes(search) : false;
253
+
254
+ return textMatch || subtextMatch;
255
+ });
256
+
257
+ localizedMenuList.value = filtered;
258
+
259
+ // If grouping is enabled, regroup the filtered list
260
+ if (groupItemsBy?.value) {
261
+ groupedMenuList.value = [{ groupLabel: 'no-group', items: [] }];
262
+ if (groupItemsBy.value === 'default') {
263
+ filtered.forEach((item) => {
264
+ const groupKey = item.group || 'no-group';
265
+
266
+ if (groupedMenuList.value.some((g) => g.groupLabel === groupKey)) {
267
+ groupedMenuList.value.find((g) => g.groupLabel === groupKey)?.items.push(item);
268
+ } else {
269
+ groupedMenuList.value.push({ groupLabel: groupKey, items: [item] });
270
+ }
271
+ });
272
+ } else {
273
+ filtered
274
+ .sort((a, b) => {
275
+ if (groupItemsBy.value === 'A-Z') return a.text.localeCompare(b.text);
276
+ if (groupItemsBy.value === 'Z-A') return b.text.localeCompare(a.text);
277
+
278
+ return 0;
279
+ })
280
+ .forEach((item) => {
281
+ const firstCharacter = item.text.charAt(0);
282
+ const groupKey = /^\d/.test(firstCharacter) ? 'no-group' : firstCharacter.toUpperCase();
283
+
284
+ if (groupedMenuList.value.some((g) => g.groupLabel === groupKey)) {
285
+ groupedMenuList.value.find((g) => g.groupLabel === groupKey)?.items.push(item);
286
+ } else {
287
+ groupedMenuList.value.push({ groupLabel: groupKey, items: [item] });
288
+ }
289
+ });
290
+ }
291
+ }
292
+ };
293
+
235
294
  const handleSelectedItem = (item: MenuListType) => {
236
- if(item.disabled) return;
295
+ if (item.disabled) return;
237
296
 
238
297
  if (multiSelect.value) {
239
298
  // For multi-select, check if item is already selected
240
299
  const index = selectedItems.value.findIndex((selectedItem: MenuListType) => {
241
300
  // Compare text values first for simple match
242
301
  if (selectedItem.text === item.text) return true;
243
-
302
+
244
303
  // Compare primitive values with string conversion for compatibility
245
304
  if (typeof selectedItem.value !== 'object' && typeof item.value !== 'object') {
246
305
  return String(selectedItem.value) === String(item.value);
247
306
  }
248
-
307
+
249
308
  // For objects, compare their JSON string representations
250
- if (typeof selectedItem.value === 'object' && selectedItem.value !== null &&
251
- typeof item.value === 'object' && item.value !== null) {
309
+ if (
310
+ typeof selectedItem.value === 'object' &&
311
+ selectedItem.value !== null &&
312
+ typeof item.value === 'object' &&
313
+ item.value !== null
314
+ ) {
252
315
  return JSON.stringify(selectedItem.value) === JSON.stringify(item.value);
253
316
  }
254
-
317
+
255
318
  // Compare _originalObject if available (most reliable for complex objects)
256
- if ('_originalObject' in selectedItem && selectedItem._originalObject &&
257
- '_originalObject' in item && item._originalObject) {
258
-
319
+ if (
320
+ '_originalObject' in selectedItem &&
321
+ selectedItem._originalObject &&
322
+ '_originalObject' in item &&
323
+ item._originalObject
324
+ ) {
259
325
  // Direct reference equality check (fastest)
260
326
  if (selectedItem._originalObject === item._originalObject) {
261
327
  return true;
262
328
  }
263
-
329
+
264
330
  // ID-based comparison (reliable for objects with IDs)
265
331
  const selectedObj = selectedItem._originalObject as Record<string, unknown>;
266
332
  const itemObj = item._originalObject as Record<string, unknown>;
267
-
333
+
268
334
  if ('id' in selectedObj && 'id' in itemObj) {
269
335
  return selectedObj.id === itemObj.id;
270
336
  }
271
-
337
+
272
338
  // Full JSON comparison (most comprehensive but slower)
273
339
  return JSON.stringify(selectedItem._originalObject) === JSON.stringify(item._originalObject);
274
340
  }
275
-
341
+
276
342
  return false;
277
343
  });
278
344
 
@@ -306,11 +372,14 @@ export const useList = (props: ListPropTypes, emit: SetupContext<ListEmitTypes>[
306
372
  });
307
373
 
308
374
  return {
375
+ searchText,
309
376
  listClasses,
310
377
  localizedMenuList,
311
378
  groupedMenuList,
379
+ isParentMenu,
312
380
  isItemSelected,
313
381
  getListItemClasses,
382
+ handleSearch,
314
383
  handleSelectedItem,
315
384
  };
316
385
  };
@@ -0,0 +1,108 @@
1
+ import type { PropType, ExtractPropTypes } from 'vue';
2
+ import type { MenuListType } from '@/components/list/list';
3
+
4
+ const PLACEMENTS_TYPES = [
5
+ 'auto',
6
+ 'auto-start',
7
+ 'auto-end',
8
+ 'top',
9
+ 'top-start',
10
+ 'top-end',
11
+ 'right',
12
+ 'right-start',
13
+ 'right-end',
14
+ 'bottom',
15
+ 'bottom-start',
16
+ 'bottom-end',
17
+ 'left',
18
+ 'left-start',
19
+ 'left-end',
20
+ ] as const;
21
+
22
+ const POPPER_STRATEGY_TYPES = ['fixed', 'absolute'] as const;
23
+
24
+ export const selectLadderizedPropTypes = {
25
+ id: {
26
+ type: String,
27
+ required: true,
28
+ },
29
+ modelValue: {
30
+ type: Array as PropType<string[]>,
31
+ default: () => [],
32
+ },
33
+ menuList: {
34
+ type: Array as PropType<MenuListType[]>,
35
+ required: true,
36
+ default: () => [],
37
+ },
38
+ label: {
39
+ type: String,
40
+ default: '',
41
+ },
42
+ placeholder: {
43
+ type: String,
44
+ default: '',
45
+ },
46
+ helperText: {
47
+ type: String,
48
+ default: '',
49
+ },
50
+ helperIcon: {
51
+ type: String,
52
+ default: null,
53
+ },
54
+ displayHelper: {
55
+ type: Boolean,
56
+ default: false,
57
+ },
58
+ clearable: {
59
+ type: Boolean,
60
+ default: false,
61
+ },
62
+ searchableMenu: {
63
+ type: Boolean,
64
+ default: false,
65
+ },
66
+ searchableMenuPlaceholder: {
67
+ type: String,
68
+ default: 'Search...',
69
+ },
70
+ placement: {
71
+ type: String as PropType<(typeof PLACEMENTS_TYPES)[number]>,
72
+ validator: (value: (typeof PLACEMENTS_TYPES)[number]) => PLACEMENTS_TYPES.includes(value),
73
+ default: 'bottom',
74
+ },
75
+ wrapperPosition: {
76
+ type: String,
77
+ default: 'relative',
78
+ },
79
+ width: {
80
+ type: String,
81
+ default: '100%',
82
+ },
83
+ popperWidth: {
84
+ type: String,
85
+ default: '100%',
86
+ },
87
+ popperStrategy: {
88
+ type: String,
89
+ validator: (value: 'fixed' | 'absolute') => POPPER_STRATEGY_TYPES.includes(value),
90
+ default: 'absolute',
91
+ },
92
+ disabled: {
93
+ type: Boolean,
94
+ default: false,
95
+ },
96
+ removeCurrentLevelInBackLabel: {
97
+ type: Boolean,
98
+ default: false,
99
+ },
100
+ };
101
+
102
+ export const selectLadderizedEmitTypes = {
103
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
104
+ 'update:modelValue': (_value: unknown) => true,
105
+ };
106
+
107
+ export type SelectLadderizedPropTypes = ExtractPropTypes<typeof selectLadderizedPropTypes>;
108
+ export type SelectLadderizedEmitTypes = typeof selectLadderizedEmitTypes;
@@ -0,0 +1,111 @@
1
+ <template>
2
+ <div :class="ladderizedClasses.baseClasses">
3
+ <label v-if="props.label" :for="props.id" :class="ladderizedClasses.labelClasses">
4
+ {{ props.label }}
5
+ </label>
6
+
7
+ <Menu
8
+ :shown="ladderizedSelectPopperState"
9
+ aria-id="ladderized-select-wrapper"
10
+ distance="4"
11
+ :placement="props.placement"
12
+ :triggers="[]"
13
+ :popper-hide-triggers="[]"
14
+ :auto-hide="false"
15
+ :disabled="isLadderizedSelectPopperDisabled"
16
+ :container="'#ladderized-select-wrapper'"
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="spr-cursor-pointer"
30
+ :placeholder="props.placeholder"
31
+ autocomplete="off"
32
+ :helper-text="props.helperText"
33
+ :helper-icon="props.helperIcon"
34
+ :display-helper="props.displayHelper"
35
+ readonly
36
+ :disabled="props.disabled"
37
+ @keyup="handleSearch"
38
+ >
39
+ <template #icon>
40
+ <div class="spr-flex spr-items-center spr-gap-1">
41
+ <Icon
42
+ v-if="props.clearable && inputText"
43
+ class="spr-cursor-pointer"
44
+ icon="ph:x"
45
+ @click.stop="handleClear"
46
+ />
47
+ <Icon icon="ph:caret-down" />
48
+ </div>
49
+ </template>
50
+ </spr-input>
51
+ </div>
52
+
53
+ <div id="ladderized-select-wrapper" :style="{ width: props.popperWidth }"></div>
54
+
55
+ <template #popper>
56
+ <div
57
+ ref="ladderizedSelectRef"
58
+ class="spr-grid spr-max-h-[300px] spr-gap-0.5 spr-overflow-y-auto spr-overflow-x-hidden"
59
+ >
60
+ <template v-if="ladderizedSelectMenuList.length > 0">
61
+ <spr-ladderized-list
62
+ v-model="ladderizedSelectModel"
63
+ :ladderized="true"
64
+ :menu-list="ladderizedSelectMenuList"
65
+ :menu-level="ladderizedSelectModel.length"
66
+ :remove-current-level-in-back-label="props.removeCurrentLevelInBackLabel"
67
+ :searchable-menu="props.searchableMenu"
68
+ :searchable-menu-placeholder="props.searchableMenuPlaceholder"
69
+ @update:model-value="handleSelectedLadderizedItem"
70
+ />
71
+ </template>
72
+ <template v-else>
73
+ <div class="spr-flex spr-items-center spr-justify-center spr-p-2 spr-text-center">
74
+ <span class="spr-body-sm-regular spr-m-0">No results found</span>
75
+ </div>
76
+ </template>
77
+ </div>
78
+ </template>
79
+ </Menu>
80
+ </div>
81
+ </template>
82
+
83
+ <script lang="ts" setup>
84
+ import { Menu } from 'floating-vue';
85
+ import { Icon } from '@iconify/vue';
86
+
87
+ import 'floating-vue/dist/style.css';
88
+
89
+ import SprInput from '@/components/input/input.vue';
90
+ import SprLadderizedList from '@/components/list/ladderized-list/ladderized-list.vue';
91
+
92
+ import { selectLadderizedPropTypes, selectLadderizedEmitTypes } from './select-ladderized';
93
+ import { useSelectLadderized } from './use-select-ladderized';
94
+
95
+ const props = defineProps(selectLadderizedPropTypes);
96
+ const emit = defineEmits(selectLadderizedEmitTypes);
97
+
98
+ const {
99
+ ladderizedClasses,
100
+ ladderizedSelectPopperState,
101
+ ladderizedSelectRef,
102
+ ladderizedSelectMenuList,
103
+ isLadderizedSelectPopperDisabled,
104
+ ladderizedSelectModel,
105
+ inputText,
106
+ handleSelectedLadderizedItem,
107
+ handleSearch,
108
+ handleClear,
109
+ handleMenuToggle,
110
+ } = useSelectLadderized(props, emit);
111
+ </script>