@imaginario27/air-ui-ds 1.0.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.
Files changed (220) hide show
  1. package/assets/css/defaults.css +55 -0
  2. package/assets/css/main.css +238 -0
  3. package/assets/css/theme/colors.css +106 -0
  4. package/assets/css/theme/primitives.css +105 -0
  5. package/assets/css/theme/ui-theme.css +454 -0
  6. package/assets/images/placeholders/missing-image-placeholder.png +0 -0
  7. package/components/accordions/Accordion.vue +31 -0
  8. package/components/accordions/AccordionGroup.vue +78 -0
  9. package/components/accordions/AccordionItem.vue +39 -0
  10. package/components/action-panels/ActionPanel.vue +49 -0
  11. package/components/alerts/Alert.vue +159 -0
  12. package/components/avatars/Avatar.vue +152 -0
  13. package/components/avatars/AvatarStack.vue +97 -0
  14. package/components/avatars/AvatarStackCounter.vue +74 -0
  15. package/components/badges/Badge.vue +221 -0
  16. package/components/badges/BadgeStack.vue +110 -0
  17. package/components/badges/IconBadge.vue +57 -0
  18. package/components/badges/IconTextBadge.vue +50 -0
  19. package/components/breadcrumbs/Breadcrumbs.vue +54 -0
  20. package/components/buttons/ActionButton.vue +395 -0
  21. package/components/buttons/ActionIconButton.vue +283 -0
  22. package/components/buttons/AlertButton.vue +125 -0
  23. package/components/buttons/AlertIconButton.vue +105 -0
  24. package/components/buttons/PaginationButton.vue +45 -0
  25. package/components/buttons/options/OptionButton.vue +61 -0
  26. package/components/buttons/options/OptionButtonGroup.vue +155 -0
  27. package/components/buttons/options/OptionButtonSlider.vue +154 -0
  28. package/components/buttons/toggle/ToggleButton.vue +142 -0
  29. package/components/buttons/toggle/ToggleButtonGroup.vue +73 -0
  30. package/components/cards/Card.vue +33 -0
  31. package/components/cards/CardActions.vue +5 -0
  32. package/components/cards/CardBody.vue +5 -0
  33. package/components/cards/CardFooter.vue +20 -0
  34. package/components/cards/CardHeader.vue +5 -0
  35. package/components/cards/CardTitle.vue +13 -0
  36. package/components/cards/specific/ContactDetailsCard.vue +47 -0
  37. package/components/cards/specific/FeatureCard.vue +59 -0
  38. package/components/cards/specific/HelpTopicCard.vue +62 -0
  39. package/components/cards/specific/MetricCard.vue +42 -0
  40. package/components/cards/specific/TestimonialCard.vue +57 -0
  41. package/components/cards/specific/subscription/CurrentActiveSubscriptionCard.vue +105 -0
  42. package/components/cards/specific/subscription/SubscriptionPlanCard.vue +178 -0
  43. package/components/cards/specific/subscription/UniqueSubscriptionPlanCard.vue +106 -0
  44. package/components/collapsibles/Collapsible.vue +33 -0
  45. package/components/content/ContentItem.vue +144 -0
  46. package/components/content/ContentItemImage.vue +125 -0
  47. package/components/dividers/Divider.vue +35 -0
  48. package/components/dividers/TextLineDivider.vue +58 -0
  49. package/components/dropdowns/DropdownMenu.vue +207 -0
  50. package/components/dropdowns/DropdownMenuActions.vue +11 -0
  51. package/components/dropdowns/DropdownMenuItem.vue +240 -0
  52. package/components/dropdowns/DropdownSelect.vue +469 -0
  53. package/components/dropdowns/DropdownSelectItem.vue +182 -0
  54. package/components/empty-states/EmptyState.vue +170 -0
  55. package/components/features/Feature.vue +77 -0
  56. package/components/forms/DataDetails.vue +7 -0
  57. package/components/forms/DataDetailsActions.vue +23 -0
  58. package/components/forms/DataDetailsFieldGroup.vue +35 -0
  59. package/components/forms/DataDetailsRow.vue +22 -0
  60. package/components/forms/Form.vue +25 -0
  61. package/components/forms/FormActions.vue +23 -0
  62. package/components/forms/FormFieldGroup.vue +35 -0
  63. package/components/forms/FormRow.vue +22 -0
  64. package/components/forms/fields/ButtonField.vue +119 -0
  65. package/components/forms/fields/CheckboxField.vue +205 -0
  66. package/components/forms/fields/DataField.vue +99 -0
  67. package/components/forms/fields/FileUploadField.vue +326 -0
  68. package/components/forms/fields/InputField.vue +371 -0
  69. package/components/forms/fields/OptionButtonsGroupField.vue +120 -0
  70. package/components/forms/fields/RepeaterField.vue +109 -0
  71. package/components/forms/fields/SearchField.vue +184 -0
  72. package/components/forms/fields/SelectField.vue +233 -0
  73. package/components/forms/fields/SliderField.vue +759 -0
  74. package/components/forms/fields/SwitchField.vue +257 -0
  75. package/components/forms/fields/TextareaField.vue +205 -0
  76. package/components/forms/fields/ToggleButtonsGroupField.vue +65 -0
  77. package/components/forms/fields/radio/RadioButtonField.vue +238 -0
  78. package/components/forms/fields/radio/RadioField.vue +157 -0
  79. package/components/forms/fields/radio/RadioGroupField.vue +156 -0
  80. package/components/icons/ContainedIcon.vue +130 -0
  81. package/components/images/QRCode.vue +124 -0
  82. package/components/layouts/ContainerWrapper.vue +13 -0
  83. package/components/layouts/ContentBody.vue +30 -0
  84. package/components/layouts/Grid.vue +25 -0
  85. package/components/layouts/Heading.vue +159 -0
  86. package/components/layouts/MainContent.vue +26 -0
  87. package/components/layouts/MaxWidthContainer.vue +15 -0
  88. package/components/layouts/Overtitle.vue +25 -0
  89. package/components/layouts/headers/CompactHeader.vue +181 -0
  90. package/components/layouts/headers/PageHeader.vue +102 -0
  91. package/components/layouts/headers/WebAppHeader.vue +54 -0
  92. package/components/layouts/section/Section.vue +90 -0
  93. package/components/layouts/section/SectionBody.vue +12 -0
  94. package/components/layouts/section/SectionHeader.vue +12 -0
  95. package/components/layouts/section/SectionTitle.vue +13 -0
  96. package/components/lists/List.vue +69 -0
  97. package/components/lists/ListItem.vue +58 -0
  98. package/components/loaders/Loading.vue +83 -0
  99. package/components/loaders/LoadingScreen.vue +285 -0
  100. package/components/modals/DangerModalDialog.vue +149 -0
  101. package/components/modals/InfoModalDialog.vue +143 -0
  102. package/components/modals/ModalActions.vue +22 -0
  103. package/components/modals/ModalContent.vue +5 -0
  104. package/components/modals/ModalDescription.vue +5 -0
  105. package/components/modals/ModalDialog.vue +122 -0
  106. package/components/modals/ModalHeaderGroup.vue +19 -0
  107. package/components/modals/ModalHeadings.vue +5 -0
  108. package/components/modals/ModalSubtitle.vue +14 -0
  109. package/components/modals/ModalTitle.vue +14 -0
  110. package/components/modals/SuccessModalDialog.vue +90 -0
  111. package/components/modules/AppLogo.vue +46 -0
  112. package/components/modules/SVGImage.vue +44 -0
  113. package/components/navigation/links/NavLink.vue +112 -0
  114. package/components/navigation/nav-menu/NavFooterMenu.vue +91 -0
  115. package/components/navigation/nav-menu/NavMenu.vue +36 -0
  116. package/components/navigation/nav-menu/NavMenuItem.vue +44 -0
  117. package/components/navigation/nav-sidebar/BottomUserNavBar.vue +83 -0
  118. package/components/navigation/nav-sidebar/NavSidebar.vue +172 -0
  119. package/components/navigation/nav-sidebar/NavSidebarMenu.vue +14 -0
  120. package/components/navigation/nav-sidebar/NavSidebarMenuItem.vue +76 -0
  121. package/components/navigation/nav-sidebar/NavSidebarMenuSectionTitle.vue +54 -0
  122. package/components/navigation/table-of-contents/TableOfContents.vue +35 -0
  123. package/components/navigation/table-of-contents/TableOfContentsItem.vue +40 -0
  124. package/components/navigation/table-of-contents/TableOfContentsSidebar.vue +29 -0
  125. package/components/pagination/ButtonPagination.vue +274 -0
  126. package/components/pagination/RowsPerPage.vue +60 -0
  127. package/components/pagination/SimplePagination.vue +97 -0
  128. package/components/password/SecurePasswordCondition.vue +41 -0
  129. package/components/password/SecurePasswordConditions.vue +83 -0
  130. package/components/placeholders/ContentPlaceholder.vue +41 -0
  131. package/components/popovers/Popover.vue +128 -0
  132. package/components/rating/InteractiveRating.vue +94 -0
  133. package/components/rating/Rating.vue +60 -0
  134. package/components/rating/RatingItem.vue +54 -0
  135. package/components/skeletons/Skeleton.vue +11 -0
  136. package/components/spinners/Spinner.vue +13 -0
  137. package/components/steppers/CircleStepper.vue +122 -0
  138. package/components/steppers/Step.vue +72 -0
  139. package/components/steppers/StepIndicator.vue +228 -0
  140. package/components/steppers/TabStepper.vue +126 -0
  141. package/components/steppers/vertical-stepper/VerticalStep.vue +223 -0
  142. package/components/steppers/vertical-stepper/VerticalStepper.vue +63 -0
  143. package/components/tables/Table.vue +26 -0
  144. package/components/tables/TableBody.vue +5 -0
  145. package/components/tables/TableCell.vue +34 -0
  146. package/components/tables/TableCellActions.vue +7 -0
  147. package/components/tables/TableHeader.vue +5 -0
  148. package/components/tables/TableHeaderCell.vue +15 -0
  149. package/components/tables/TableRow.vue +14 -0
  150. package/components/tables/TableWrapper.vue +12 -0
  151. package/components/tabs/Tab.vue +145 -0
  152. package/components/tabs/TabBar.vue +64 -0
  153. package/components/tabs/TabContent.vue +5 -0
  154. package/components/tabs/TabsContainer.vue +5 -0
  155. package/components/transitions/HorizontalExpansionTransition.vue +12 -0
  156. package/components/transitions/VerticalExpansionTransition.vue +14 -0
  157. package/components/users/Author.vue +113 -0
  158. package/components/users/User.vue +53 -0
  159. package/composables/useAccordion.ts +12 -0
  160. package/composables/useDarkMode.ts +9 -0
  161. package/composables/useDropdownMenu.ts +25 -0
  162. package/composables/useForm.ts +134 -0
  163. package/composables/useFormValidationMode.ts +11 -0
  164. package/composables/useIsMobile.ts +27 -0
  165. package/composables/useMobileSidebar.ts +32 -0
  166. package/composables/useShiki.ts +12 -0
  167. package/composables/useTableOfContents.ts +50 -0
  168. package/composables/useToastifyConfig.ts +7 -0
  169. package/eslint.config.mjs +14 -0
  170. package/models/constants/app.ts +8 -0
  171. package/models/constants/form.ts +22 -0
  172. package/models/enums/alerts.ts +6 -0
  173. package/models/enums/aspect-ratios.ts +9 -0
  174. package/models/enums/avatars.ts +21 -0
  175. package/models/enums/badges.ts +10 -0
  176. package/models/enums/buttons.ts +38 -0
  177. package/models/enums/colors.ts +9 -0
  178. package/models/enums/content.ts +4 -0
  179. package/models/enums/counters.ts +4 -0
  180. package/models/enums/dividers.ts +9 -0
  181. package/models/enums/dropdowns.ts +18 -0
  182. package/models/enums/effects.ts +6 -0
  183. package/models/enums/emptyPlaceholders.ts +5 -0
  184. package/models/enums/formFields.ts +19 -0
  185. package/models/enums/formValidations.ts +4 -0
  186. package/models/enums/headings.ts +11 -0
  187. package/models/enums/icons.ts +22 -0
  188. package/models/enums/images.ts +16 -0
  189. package/models/enums/lists.ts +10 -0
  190. package/models/enums/loaders.ts +15 -0
  191. package/models/enums/navigation.ts +18 -0
  192. package/models/enums/order.ts +10 -0
  193. package/models/enums/orientations.ts +4 -0
  194. package/models/enums/pages.ts +10 -0
  195. package/models/enums/positions.ts +21 -0
  196. package/models/enums/rating.ts +12 -0
  197. package/models/enums/sections.ts +8 -0
  198. package/models/enums/selects.ts +16 -0
  199. package/models/enums/sliders.ts +4 -0
  200. package/models/enums/steppers.ts +20 -0
  201. package/models/enums/tabs.ts +11 -0
  202. package/models/enums/triggers.ts +4 -0
  203. package/models/types/accordions.ts +6 -0
  204. package/models/types/avatars.ts +4 -0
  205. package/models/types/badges.ts +4 -0
  206. package/models/types/buttons.ts +26 -0
  207. package/models/types/dropdowns.ts +20 -0
  208. package/models/types/forms.ts +14 -0
  209. package/models/types/navigation.ts +11 -0
  210. package/models/types/pagination.ts +4 -0
  211. package/models/types/pdfExportTable.ts +6 -0
  212. package/models/types/radio.ts +9 -0
  213. package/models/types/selects.ts +14 -0
  214. package/models/types/steppers.ts +17 -0
  215. package/models/types/tableOfContent.ts +6 -0
  216. package/models/types/tabs.ts +7 -0
  217. package/nuxt.config.ts +40 -0
  218. package/package.json +57 -0
  219. package/plugins/vue3-toastify.ts +14 -0
  220. package/tsconfig.json +7 -0
@@ -0,0 +1,469 @@
1
+ <template>
2
+ <div class="relative w-full">
3
+ <!-- Dropdown Menu -->
4
+ <DropdownMenu
5
+ ref="dropdownContainer"
6
+ :positionClass="`absolute ${dropdownPositionClass}`"
7
+ dropdownClass="max-w-full z-10"
8
+ :class="[
9
+ 'max-h-[200px]',
10
+ 'overflow-y-auto',
11
+ 'border',
12
+ 'border-border-default',
13
+ ]"
14
+ >
15
+ <template #activator="{ onClick, isOpen }">
16
+ <!-- Select Box -->
17
+ <div
18
+ :class="[
19
+ 'select-box', // Class identifier for unit test
20
+ 'flex items-center justify-between',
21
+ 'w-full',
22
+ 'px-3',
23
+ disabled && 'bg-background-neutral-disabled',
24
+ 'rounded',
25
+ disabled ? 'cursor-not-allowed' : 'cursor-pointer',
26
+ 'border border-border-default',
27
+ selectBoxClass,
28
+ 'text-sm',
29
+ disabled ? 'text-text-neutral-disabled' : 'text-text-default',
30
+ sizeClass,
31
+ ]"
32
+ @click="!disabled && onClick()"
33
+ >
34
+ <div v-if="multiple">
35
+ <template v-if="Array.isArray(selected) && selected.length">
36
+ <div class="flex flex-wrap gap-1">
37
+ {{ trimText(multiSelectionText, 40) }}
38
+ </div>
39
+ </template>
40
+ <template v-else>
41
+ <span class="text-text-neutral-subtle">
42
+ {{ placeholder }}
43
+ </span>
44
+ </template>
45
+ </div>
46
+
47
+ <div
48
+ v-else
49
+ class="flex items-center gap-2"
50
+ >
51
+ <!-- Render based on type -->
52
+ <template v-if="type === SelectType.USER">
53
+ <!-- Show default placeholder if no user is selected -->
54
+ <template v-if="!selectedOption?.userDisplayName">
55
+ <span>{{ placeholder }}</span>
56
+ </template>
57
+ <!-- Show selected user's avatar and displayName -->
58
+ <template v-else>
59
+ <User
60
+ :displayName="selectedOption?.userDisplayName"
61
+ :imgUrl="selectedOption?.userProfileImg"
62
+ :size="AvatarSize.XS"
63
+ />
64
+ </template>
65
+ </template>
66
+ <template v-else-if="type === SelectType.ICON && selectedOption?.icon">
67
+ <MdiIcon
68
+ :icon="selectedOption?.icon"
69
+ size="20px"
70
+ preserveAspectRatio="xMidYMid meet"
71
+ />
72
+ </template>
73
+ <template v-else-if="type === SelectType.ICON && selectedOption?.customIcon">
74
+ <div
75
+ class="w-[20px] h-[20px] text-icon-neutral-subtle"
76
+ v-html="selectedOption?.customIcon"
77
+ />
78
+ </template>
79
+ <template v-else-if="type === SelectType.IMAGE && selectedOption?.imgUrl">
80
+ <img
81
+ v-if="selectedOption?.imgUrl && isImageLoaded"
82
+ :src="selectedOption?.imgUrl"
83
+ :alt="selectedOption?.alt"
84
+ class="w-[20px] h-[20px] rounded"
85
+ @load="handleImageLoad"
86
+ @error="handleImageError"
87
+ >
88
+ <img
89
+ v-else
90
+ :src="missingImagePlaceholder"
91
+ :alt="selectedOption?.alt"
92
+ class="w-[20px] h-[20px] rounded"
93
+ >
94
+ </template>
95
+ <span
96
+ v-if="type !== SelectType.USER"
97
+ class="text-left"
98
+ >
99
+ {{ selectedOption?.text }}
100
+ </span>
101
+ </div>
102
+
103
+ <div class="flex gap-2 items-center">
104
+ <!-- Clear button -->
105
+ <ActionIconButton
106
+ v-if="multiple && Array.isArray(selected) && selected.length"
107
+ :size="ButtonSize.SM"
108
+ :styleType="ButtonStyleType.NEUTRAL_TRANSPARENT_SUBTLE"
109
+ icon="mdiCloseCircle"
110
+ @click="selected = []"
111
+ />
112
+
113
+ <!-- Show loading icon while loading instead of the icon-->
114
+ <Spinner v-if="isLoading" />
115
+ <MdiIcon
116
+ v-else
117
+ :icon="isOpen ? 'mdiUnfoldLessHorizontal' : 'mdiUnfoldMoreHorizontal'"
118
+ size="20px"
119
+ preserveAspectRatio="xMidYMid meet"
120
+ />
121
+ </div>
122
+ </div>
123
+ </template>
124
+
125
+ <template #items="{ onClose }">
126
+ <!-- Show loading state if it is loading -->
127
+ <div v-if="isLoading" class="p-4 flex items-center justify-center space-x-2 text-sm text-text-neutral-subtle">
128
+ <Spinner/>
129
+ <span>{{ loadingText }}</span>
130
+ </div>
131
+ <template v-else>
132
+ <!-- Search Input -->
133
+ <div
134
+ v-if="filterable"
135
+ :class="[
136
+ 'p-2',
137
+ 'sticky',
138
+ 'top-0',
139
+ 'bg-white',
140
+ 'z-10'
141
+ ]"
142
+ >
143
+ <input
144
+ v-model="searchQuery"
145
+ type="text"
146
+ :placeholder="searchFieldPlaceholder"
147
+ :class="[
148
+ 'w-full',
149
+ 'px-2',
150
+ 'py-1',
151
+ 'border',
152
+ 'border-border-default',
153
+ 'rounded-sm',
154
+ 'text-sm',
155
+ searchQuery ? 'text-text-default' : 'text-text-neutral-subtle'
156
+ ]"
157
+ >
158
+ </div>
159
+
160
+ <!-- Filtered options -->
161
+ <div v-if="filteredOptions.length > 0">
162
+ <DropdownSelectItem
163
+ v-for="(option, index) in filteredOptions"
164
+ :key="index"
165
+ :type="type"
166
+ :text="option.text"
167
+ :icon="option.icon"
168
+ :customIcon="option.customIcon"
169
+ :userDisplayName="option.userDisplayName"
170
+ :userProfileImg="option.userProfileImg"
171
+ :imgUrl="option.imgUrl"
172
+ :alt="option.alt"
173
+ :helpText="option.helpText"
174
+ :isSelected="isSelected(option)"
175
+ :activeStyle="activeStyle"
176
+ :to="option.to"
177
+ :isExternal="option.isExternal"
178
+ :class="[
179
+ hasSeparator && index !== filteredOptions.length - 1
180
+ ? 'border-b border-border-default'
181
+ : undefined
182
+ ]"
183
+ @click="() => {
184
+ handleOptionClick(option)
185
+ if (!multiple) onClose()
186
+ }"
187
+ />
188
+ </div>
189
+
190
+ <!-- No Results Message -->
191
+ <div v-else class="p-2 text-sm text-text-neutral-subtle">
192
+ {{ noResultsFoundText }}
193
+ </div>
194
+ </template>
195
+ </template>
196
+ </DropdownMenu>
197
+ </div>
198
+ </template>
199
+
200
+ <script setup lang="ts">
201
+ // Imports
202
+ import missingImagePlaceholder from '@/assets/images/placeholders/missing-image-placeholder.png'
203
+
204
+ // Props
205
+ const props = defineProps({
206
+ id: String as PropType<string>,
207
+ options: {
208
+ type: Array as PropType<SelectOption[]>,
209
+ default: () => [
210
+ {
211
+ text: 'Item 1',
212
+ value: 'item-1',
213
+ },
214
+ {
215
+ text: 'Item 2',
216
+ value: 'item-2',
217
+ },
218
+ {
219
+ text: 'Item 3',
220
+ value: 'item-3',
221
+ },
222
+ ]
223
+ },
224
+ placeholder: {
225
+ type: String as PropType<string>,
226
+ default: 'Select an option',
227
+ },
228
+ type: {
229
+ type: String as PropType<SelectType>,
230
+ default: SelectType.TEXT,
231
+ validator: (value: SelectType) => Object.values(SelectType).includes(value),
232
+ },
233
+ size: {
234
+ type: String as PropType<SelectSize>,
235
+ default: SelectSize.MD,
236
+ validator: (value: SelectSize) => Object.values(SelectSize).includes(value),
237
+ },
238
+ activeStyle: {
239
+ type: String as PropType<SelectActiveStyle>,
240
+ default: SelectActiveStyle.CHECK,
241
+ validator: (value: SelectActiveStyle) => Object.values(SelectActiveStyle).includes(value),
242
+ },
243
+ modelValue: {
244
+ type: [String, Number, Object, Array] as PropType<string | number | (string | number)[] | null>,
245
+ default: null,
246
+ },
247
+ dropdownPosition: {
248
+ type: String as PropType<Position>,
249
+ default: Position.BOTTOM,
250
+ validator: (value: Position) => Object.values(Position).includes(value),
251
+ },
252
+ selectBoxClass: String as PropType<string>,
253
+ filterable: {
254
+ type: Boolean as PropType<boolean>,
255
+ default: false,
256
+ },
257
+ searchFieldPlaceholder: {
258
+ type: String as PropType<string>,
259
+ default: 'Search...',
260
+ },
261
+ noResultsFoundText: {
262
+ type: String as PropType<string>,
263
+ default: 'No results found',
264
+ },
265
+ disabled: {
266
+ type: Boolean as PropType<boolean>,
267
+ default: false,
268
+ },
269
+ hasSeparator: {
270
+ type: Boolean as PropType<boolean>,
271
+ default: false,
272
+ },
273
+ multiple: {
274
+ type: Boolean as PropType<boolean>,
275
+ default: false,
276
+ },
277
+ allowDeselect: {
278
+ type: Boolean as PropType<boolean>,
279
+ default: false,
280
+ },
281
+ isLoading: {
282
+ type: Boolean as PropType<boolean>,
283
+ default: false,
284
+ },
285
+ loadingText: {
286
+ type: String as PropType<string>,
287
+ default: 'Loading options...',
288
+ },
289
+ })
290
+
291
+ // Emits
292
+ const emit = defineEmits(['update:modelValue', 'onSelect'])
293
+
294
+ // Computed classes
295
+ const sizeClass = computed(() => {
296
+ const sizeVariant = {
297
+ [SelectSize.MD]: 'min-h-[36px]',
298
+ [SelectSize.LG]: 'min-h-[44px]',
299
+ }
300
+ return sizeVariant[props.size as SelectSize] || 'min-h-[36px]'
301
+ })
302
+
303
+ const dropdownPositionClass = computed(() => {
304
+ const positionVariant = {
305
+ [Position.TOP]: 'bottom-full mb-1',
306
+ [Position.BOTTOM]: 'top-full mt-1',
307
+ }
308
+
309
+ return positionVariant[props.dropdownPosition as Position] || 'top-full mt-1'
310
+ })
311
+
312
+ // States
313
+ const isImageLoaded = ref(true)
314
+ const selected = ref<SelectOption[] | SelectOption | null>(props.multiple ? [] : null)
315
+ const searchQuery = ref('')
316
+
317
+ // Ref
318
+ const dropdownContainer = ref(null)
319
+
320
+ // Handlers for image load and error
321
+ const handleImageLoad = () => {
322
+ isImageLoaded.value = true
323
+ }
324
+
325
+ const handleImageError = () => {
326
+ isImageLoaded.value = false
327
+ }
328
+
329
+ // Initialize the selected state dynamically based on the 'modelValue' (key options: id or value)
330
+ const initializeSelected = () => {
331
+ const hasOptions = Array.isArray(props.options) && props.options.length > 0
332
+
333
+ if (props.modelValue) {
334
+ if (props.multiple && Array.isArray(props.modelValue)) {
335
+ selected.value = hasOptions
336
+ ? props.options.filter(option =>
337
+ (props.modelValue as (string | number)[]).includes(option.value)
338
+ )
339
+ : []
340
+ } else {
341
+ const preselectedOption = hasOptions
342
+ ? props.options.find(
343
+ (option) => option.id === props.modelValue || option.value === props.modelValue
344
+ )
345
+ : null
346
+
347
+ selected.value = preselectedOption
348
+ ? { ...preselectedOption }
349
+ : { text: props.placeholder, value: '' }
350
+ }
351
+ } else {
352
+ const defaultValue = hasOptions ? props.options[0]!.value : ''
353
+
354
+ if (props.multiple) {
355
+ selected.value = []
356
+ } else {
357
+ switch (props.type) {
358
+ case SelectType.ICON:
359
+ selected.value = { text: props.placeholder, icon: '', value: defaultValue }
360
+ break
361
+ case SelectType.USER:
362
+ selected.value = { userDisplayName: '', userProfileImg: '', value: defaultValue }
363
+ break
364
+ case SelectType.IMAGE:
365
+ selected.value = { text: props.placeholder, imgUrl: '', alt: 'Default image', value: defaultValue }
366
+ break
367
+ default:
368
+ selected.value = { text: props.placeholder, value: defaultValue }
369
+ break
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Concatenated string with text or userDisplayName
376
+ const multiSelectionText = computed(() => {
377
+ if (!props.multiple || !Array.isArray(selected.value)) return ''
378
+
379
+ return selected.value
380
+ .map(option => props.type === SelectType.USER ? option.userDisplayName : option.text)
381
+ .filter(Boolean)
382
+ .join(', ')
383
+ })
384
+
385
+ // Check if the option is selected based on the `type`
386
+ const isSelected = (option: SelectOption) => {
387
+ if (props.multiple && Array.isArray(props.modelValue)) {
388
+ return (selected.value as SelectOption[]).some(
389
+ (item) => item.value === option.value
390
+ )
391
+ }
392
+ return JSON.stringify(selected.value) === JSON.stringify(option)
393
+ }
394
+
395
+ const selectedOption = computed(() => {
396
+ return !props.multiple && selected.value && !Array.isArray(selected.value)
397
+ ? selected.value as SelectOption
398
+ : null
399
+ })
400
+
401
+ // Computed: Filtered options based on search query
402
+ const filteredOptions = computed(() => {
403
+
404
+ if (props.isLoading || !Array.isArray(props.options) || props.options.length === 0) return []
405
+
406
+ return props.options.filter(option =>
407
+ (option?.text || option?.userDisplayName || '')
408
+ .toLowerCase()
409
+ .includes(searchQuery.value.toLowerCase())
410
+ )
411
+ })
412
+
413
+ // Updates visually the component when the value has changed
414
+ watch(() => props.modelValue, (newValue) => {
415
+ if (props.multiple) {
416
+ if (Array.isArray(newValue)) {
417
+ selected.value = props.options.filter(option => newValue.includes(option.value))
418
+ } else {
419
+ selected.value = []
420
+ }
421
+
422
+ } else {
423
+ const newSelected = props.options.find(option => option.value === newValue) as SelectOption
424
+ selected.value = newSelected || { text: props.placeholder } // Default to placeholder if no match
425
+ }
426
+ })
427
+
428
+ // Method
429
+ const handleOptionClick = (option: SelectOption) => {
430
+ if (props.disabled || props.isLoading) return
431
+
432
+ const optionValue = option.value
433
+
434
+ // Multiple select with deselect
435
+ if (props.multiple) {
436
+ // Multiple selection
437
+ const currentValues = Array.isArray(props.modelValue) ? props.modelValue : []
438
+ const isSelected = currentValues.includes(optionValue)
439
+
440
+ if (isSelected) {
441
+ // If selected, deselect.
442
+ const newValues = currentValues.filter(val => val !== optionValue)
443
+ emit('update:modelValue', newValues)
444
+ } else {
445
+ // Add to selection
446
+ emit('update:modelValue', [...currentValues, optionValue])
447
+ }
448
+ } else {
449
+ // Simple select
450
+ if (props.allowDeselect && props.modelValue === optionValue) {
451
+ // Deselect if this options is enabled
452
+ emit('update:modelValue', null)
453
+ } else {
454
+ // Select new option
455
+ emit('update:modelValue', optionValue)
456
+ }
457
+
458
+ // Close dropdown for simple selection
459
+ toggle()
460
+ }
461
+ }
462
+
463
+ // Composables
464
+ const [_, toggle] = useToggle(false)
465
+
466
+ onMounted(() => {
467
+ initializeSelected()
468
+ })
469
+ </script>
@@ -0,0 +1,182 @@
1
+ <template>
2
+ <component
3
+ :is="dynamicComponent"
4
+ v-bind="componentProps"
5
+ :class="[
6
+ 'flex flex-col',
7
+ 'px-3',
8
+ 'text-sm',
9
+ 'hover:cursor-pointer',
10
+ 'w-full',
11
+ sizeClass,
12
+ isSelected && activeStyle === SelectActiveStyle.FILL ?
13
+ 'bg-background-primary-brand-active hover:background-primary-brand-active hover:text-text-neutral-on-filled'
14
+ : 'hover:bg-background-neutral-hover-subtle',
15
+ ]"
16
+ @click="emitClick"
17
+ >
18
+ <div class="flex justify-between items-center gap-3 w-full">
19
+ <div class="flex items-center gap-3">
20
+ <MdiIcon
21
+ v-if="(type === SelectType.ICON) && !customIcon"
22
+ :icon="icon"
23
+ size="20"
24
+ preserveAspectRatio="xMidYMid meet"
25
+ :class="[
26
+ 'min-w-[20px]',
27
+ isSelected && activeStyle === SelectActiveStyle.FILL ? 'text-icon-on-filled' : 'text-icon-neutral-subtle'
28
+ ]"
29
+ />
30
+ <!-- Custom icon (replaces the MdiIcon if it is used)-->
31
+ <div
32
+ v-if="(type === SelectType.ICON) && customIcon"
33
+ class="w-[20px] h-[20px]"
34
+ :class="isSelected && activeStyle === SelectActiveStyle.FILL ? 'text-icon-on-filled' : 'text-icon-neutral-subtle'"
35
+ v-html="customIcon"
36
+ />
37
+ <template v-if="type === SelectType.IMAGE">
38
+ <img
39
+ v-if="imgUrl && isImageLoaded"
40
+ :src="imgUrl"
41
+ :alt
42
+ class="w-[24px] h-[24px] rounded"
43
+ @load="handleImageLoad"
44
+ @error="handleImageError"
45
+ >
46
+ <img
47
+ v-else
48
+ :src="missingImagePlaceholder"
49
+ :alt
50
+ class="w-[24px] h-[24px] rounded"
51
+ >
52
+ </template>
53
+ <span
54
+ v-if="type !== SelectType.USER"
55
+ :class="[
56
+ 'text-sm text-left',
57
+ isSelected ? 'font-semibold' : '',
58
+ isSelected && activeStyle === SelectActiveStyle.FILL
59
+ ? 'text-text-neutral-on-filled' : ''
60
+ ]"
61
+ >
62
+ {{ text }}
63
+ </span>
64
+ <User
65
+ v-if="type === SelectType.USER"
66
+ :displayName="userDisplayName"
67
+ :imgUrl="userProfileImg"
68
+ :size="AvatarSize.XS"
69
+ :class="isSelected && activeStyle === SelectActiveStyle.FILL ? '!text-text-neutral-on-filled' : ''"
70
+ />
71
+ </div>
72
+ <MdiIcon
73
+ v-if="isSelected && activeStyle === SelectActiveStyle.CHECK"
74
+ icon="mdiCheck"
75
+ size="20px"
76
+ preserveAspectRatio="xMidYMid meet"
77
+ class="text-icon-primary-brand-active"
78
+ />
79
+ </div>
80
+ <p
81
+ v-if="helpText"
82
+ class="text-xs text-text-neutral-subtle mt-2"
83
+ >
84
+ {{ helpText }}
85
+ </p>
86
+ </component>
87
+ </template>
88
+
89
+ <script setup lang="ts">
90
+ // Imports
91
+ import missingImagePlaceholder from '@/assets/images/placeholders/missing-image-placeholder.png'
92
+
93
+ // Props
94
+ const props = defineProps({
95
+ text: {
96
+ type: String as PropType<string>,
97
+ default: 'Select menu item',
98
+ },
99
+ icon: {
100
+ type: String as PropType<any>,
101
+ default: 'mdiHelp',
102
+ },
103
+ customIcon: String as PropType<string>,
104
+ size: {
105
+ type: String as PropType<SelectSize>,
106
+ default: SelectSize.MD,
107
+ validator: (value: SelectSize) => Object.values(SelectSize).includes(value),
108
+ },
109
+ type: {
110
+ type: String as PropType<SelectType>,
111
+ default: SelectType.TEXT,
112
+ validator: (value: SelectType) => Object.values(SelectType).includes(value),
113
+ },
114
+ userDisplayName: {
115
+ type: String as PropType<string>,
116
+ default: 'Test user',
117
+ },
118
+ userProfileImg: String as PropType<string>,
119
+ imgUrl: String as PropType<string>,
120
+ alt: {
121
+ type: String as PropType<string>,
122
+ default: 'Menu item image',
123
+ },
124
+ helpText: String as PropType<string>,
125
+ isSelected: {
126
+ type: Boolean as PropType<boolean>,
127
+ default: false,
128
+ },
129
+ activeStyle: {
130
+ type: String as PropType<SelectActiveStyle>,
131
+ default: SelectActiveStyle.CHECK,
132
+ validator: (value: SelectActiveStyle) => Object.values(SelectActiveStyle).includes(value),
133
+ },
134
+ to: String as PropType<string>,
135
+ isExternal: {
136
+ type: Boolean as PropType<boolean>,
137
+ default: false,
138
+ },
139
+ })
140
+
141
+ // States
142
+ const isImageLoaded = ref(true)
143
+
144
+ // Emits
145
+ const emit = defineEmits(['click'])
146
+ const emitClick = () => {
147
+ emit('click')
148
+ }
149
+
150
+ // Handlers for image load and error
151
+ const handleImageLoad = () => {
152
+ isImageLoaded.value = true
153
+ }
154
+
155
+ const handleImageError = () => {
156
+ isImageLoaded.value = false
157
+ }
158
+
159
+ // Computed
160
+ const sizeClass = computed(() => {
161
+ const sizeVariant = {
162
+ [SelectSize.MD]: 'py-2',
163
+ [SelectSize.LG]: 'py-3',
164
+ }
165
+ return sizeVariant[props.size as SelectSize] || 'py-2'
166
+ })
167
+
168
+ const dynamicComponent = computed(() => {
169
+ return props.to ? 'a': 'div'
170
+ })
171
+
172
+ const componentProps = computed(() => {
173
+ if (props.to) {
174
+ return {
175
+ href: props.to,
176
+ ...(props.isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {}),
177
+ }
178
+ } else {
179
+ return {}
180
+ }
181
+ })
182
+ </script>