mgv-backoffice 1.36.0 → 1.36.2

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 (60) hide show
  1. package/README.md +1794 -1719
  2. package/dist/components/BaseBadge.vue.d.ts +1 -11
  3. package/dist/components/BaseDropdown.vue.d.ts +1 -1
  4. package/dist/ui-lib.js +942 -925
  5. package/dist/ui-lib.umd.cjs +1 -1
  6. package/dist/utils/specForm.d.ts +1 -1
  7. package/package.json +54 -54
  8. package/src/components/BaseAlert.vue +104 -104
  9. package/src/components/BaseBadge.vue +12 -2
  10. package/src/components/BaseBarDistribution.test.ts +51 -51
  11. package/src/components/BaseBarDistribution.vue +73 -73
  12. package/src/components/BaseChipButton.vue +42 -42
  13. package/src/components/BaseCodeBlock.test.ts +70 -70
  14. package/src/components/BaseCodeBlock.vue +74 -74
  15. package/src/components/BaseCollapsibleSection.vue +113 -113
  16. package/src/components/BaseConfirmModal.vue +114 -114
  17. package/src/components/BaseCredentialsForm.test.ts +108 -108
  18. package/src/components/BaseCredentialsForm.vue +241 -241
  19. package/src/components/BaseDropdown.test.ts +87 -87
  20. package/src/components/BaseDropdown.vue +172 -172
  21. package/src/components/BaseEntityPickerModal.vue +330 -330
  22. package/src/components/BaseFileDropzone.vue +94 -94
  23. package/src/components/BaseFilterChip.test.ts +44 -44
  24. package/src/components/BaseFilterChip.vue +67 -67
  25. package/src/components/BaseModalShell.vue +209 -209
  26. package/src/components/BasePageHeader.vue +120 -120
  27. package/src/components/BasePillPickerModal.test.ts +95 -95
  28. package/src/components/BaseRemoveButton.vue +22 -22
  29. package/src/components/BaseSpecFields.test.ts +39 -0
  30. package/src/components/BaseSpecFields.vue +27 -3
  31. package/src/components/BaseStatBreakdown.test.ts +56 -56
  32. package/src/components/BaseStatBreakdown.vue +49 -49
  33. package/src/components/BaseStatusPill.vue +54 -54
  34. package/src/components/BaseTextInputModal.vue +140 -140
  35. package/src/components/BaseToolbarButton.test.ts +48 -48
  36. package/src/components/BaseToolbarButton.vue +94 -94
  37. package/src/composables/useFieldClasses.test.ts +55 -55
  38. package/src/composables/useFieldClasses.ts +56 -56
  39. package/src/composables/usePolling.test.ts +112 -112
  40. package/src/composables/usePolling.ts +102 -102
  41. package/src/composables/useQueryParamSync.test.ts +56 -56
  42. package/src/composables/useQueryParamSync.ts +42 -42
  43. package/src/composables/useThemeClasses.test.ts +34 -34
  44. package/src/composables/useThemeClasses.ts +137 -137
  45. package/src/index.ts +138 -138
  46. package/src/style.css +32 -32
  47. package/src/types/distribution.ts +7 -7
  48. package/src/types/pillPicker.ts +14 -14
  49. package/src/types/specField.ts +30 -30
  50. package/src/types/statBreakdown.ts +6 -6
  51. package/src/utils/format.test.ts +264 -264
  52. package/src/utils/format.ts +216 -216
  53. package/src/utils/httpColors.test.ts +100 -100
  54. package/src/utils/httpColors.ts +99 -99
  55. package/src/utils/kvRows.test.ts +47 -47
  56. package/src/utils/kvRows.ts +34 -34
  57. package/src/utils/specForm.test.ts +64 -53
  58. package/src/utils/specForm.ts +49 -48
  59. package/src/utils/validate.test.ts +82 -82
  60. package/src/utils/validate.ts +76 -76
@@ -1,330 +1,330 @@
1
- <template>
2
- <BaseModalShell
3
- :title="title"
4
- :icon="FolderIcon"
5
- :icon-bg-class="palette.iconBg"
6
- :icon-class="palette.iconText"
7
- :manual-close="submitting"
8
- @cancel="handleCancel"
9
- >
10
- <!-- Forward a caller-supplied icon override; otherwise the shell
11
- renders the default folder icon in the palette-tinted chip. -->
12
- <template v-if="$slots.icon" #icon>
13
- <slot name="icon" />
14
- </template>
15
-
16
- <p v-if="message" class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
17
- {{ message }}
18
- </p>
19
-
20
- <!-- Loading -->
21
- <div v-if="loading" class="mt-4 flex justify-center py-6">
22
- <BaseSpinner color="emerald" />
23
- </div>
24
-
25
- <!-- Error -->
26
- <div v-else-if="loadError" class="mt-4 text-sm text-red-500" role="alert">
27
- {{ loadError }}
28
- </div>
29
-
30
- <!-- Search input -->
31
- <div v-if="!loading && !loadError && items.length > 0" class="mt-4">
32
- <div class="relative">
33
- <MagnifyingGlassIcon
34
- class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
35
- :class="t.dimText"
36
- aria-hidden="true"
37
- />
38
- <input
39
- ref="searchInputRef"
40
- v-model="searchQuery"
41
- type="text"
42
- :placeholder="searchPlaceholder"
43
- class="w-full pl-9 pr-3 py-2 text-sm rounded-md border outline-none transition-colors"
44
- :class="[
45
- isDark
46
- ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500'
47
- : 'bg-white border-gray-300 text-gray-700 placeholder-gray-400',
48
- palette.inputFocus,
49
- ]"
50
- />
51
- </div>
52
- </div>
53
-
54
- <!-- Item list -->
55
- <div v-if="!loading && !loadError" class="mt-3 max-h-64 overflow-y-auto">
56
- <button
57
- v-for="item in filteredItems"
58
- :key="item.id"
59
- type="button"
60
- :aria-pressed="selectedId === item.id"
61
- class="w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
62
- :class="[
63
- selectedId === item.id
64
- ? palette.selectedRow
65
- : isDark
66
- ? 'hover:bg-gray-800 border border-transparent'
67
- : 'hover:bg-gray-50 border border-transparent'
68
- ]"
69
- @click="selectedId = item.id"
70
- >
71
- <FolderIcon class="w-5 h-5 flex-shrink-0" :class="t.dimText" />
72
- <div class="flex-1 min-w-0">
73
- <p class="text-sm font-medium truncate" :class="t.bodyText">
74
- {{ item.label }}
75
- </p>
76
- </div>
77
- <div v-if="selectedId === item.id"
78
- class="w-5 h-5 rounded-full flex items-center justify-center"
79
- :class="palette.checkBg">
80
- <svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" aria-hidden="true">
81
- <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
82
- </svg>
83
- </div>
84
- </button>
85
-
86
- <!-- Empty states -->
87
- <div v-if="items.length === 0" class="text-center py-6">
88
- <p class="text-sm" :class="t.dimText">
89
- {{ emptyMessage }}
90
- </p>
91
- </div>
92
- <div v-else-if="filteredItems.length === 0" class="text-center py-6">
93
- <p class="text-sm" :class="t.dimText">
94
- {{ noMatchMessage }}
95
- </p>
96
- </div>
97
- </div>
98
-
99
- <template #footer>
100
- <button
101
- type="button"
102
- :disabled="submitting"
103
- class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
104
- :class="t.ghostButton"
105
- @click="handleCancel"
106
- >
107
- {{ cancelText }}
108
- </button>
109
- <button
110
- type="button"
111
- :disabled="!selectedId || submitting"
112
- class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
113
- :class="!selectedId || submitting ? palette.confirmDisabled : palette.confirmActive"
114
- @click="handleConfirm"
115
- >
116
- {{ submitting ? submittingText : confirmText }}
117
- </button>
118
- </template>
119
- </BaseModalShell>
120
- </template>
121
-
122
- <script setup lang="ts">
123
- import { ref, computed, onMounted, nextTick } from 'vue'
124
- import { FolderIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
125
- import BaseModalShell from './BaseModalShell.vue'
126
- import BaseSpinner from './BaseSpinner.vue'
127
- import { useTheme } from '../composables/useTheme'
128
- import { useThemeClasses } from '../composables/useThemeClasses'
129
- import type { EntityPickerItem } from '../types/entityPicker'
130
-
131
- type Variant = 'emerald' | 'purple' | 'blue' | 'red' | 'amber'
132
-
133
- interface PaletteClasses {
134
- iconBg: string
135
- iconText: string
136
- inputFocus: string
137
- selectedRow: string
138
- checkBg: string
139
- confirmActive: string
140
- confirmDisabled: string
141
- }
142
-
143
- interface Props {
144
- title: string
145
- message?: string
146
- /** Pre-loaded items. Pass these instead of `loader` when the items are already in memory. */
147
- items?: EntityPickerItem[]
148
- /** Async loader called on mount. Use when items live in a backend service. */
149
- loader?: () => Promise<EntityPickerItem[]>
150
- /** ID to exclude from the list (e.g. the current entity). */
151
- excludeId?: string
152
- /** Accent colour for selection / confirm. Defaults to 'emerald'. */
153
- variant?: Variant
154
- searchPlaceholder?: string
155
- emptyMessage?: string
156
- noMatchMessage?: string
157
- confirmText?: string
158
- cancelText?: string
159
- submittingText?: string
160
- submitting?: boolean
161
- }
162
-
163
- const props = withDefaults(defineProps<Props>(), {
164
- items: () => [],
165
- variant: 'emerald',
166
- searchPlaceholder: 'Search...',
167
- emptyMessage: 'No items available.',
168
- noMatchMessage: 'No items match your search.',
169
- confirmText: 'Select',
170
- cancelText: 'Cancel',
171
- submittingText: 'Working...',
172
- submitting: false,
173
- })
174
-
175
- const emit = defineEmits<{
176
- confirm: [itemId: string]
177
- cancel: []
178
- }>()
179
-
180
- const { isDark } = useTheme()
181
- const t = useThemeClasses()
182
-
183
- const loadedItems = ref<EntityPickerItem[]>([])
184
- const loading = ref(false)
185
- const loadError = ref('')
186
- const selectedId = ref<string | null>(null)
187
- const searchQuery = ref('')
188
- const searchInputRef = ref<HTMLInputElement | null>(null)
189
-
190
- const items = computed<EntityPickerItem[]>(() => {
191
- const source = props.loader ? loadedItems.value : props.items
192
- return props.excludeId
193
- ? source.filter(i => i.id !== props.excludeId)
194
- : source
195
- })
196
-
197
- const filteredItems = computed(() =>
198
- items.value.filter(i =>
199
- i.label.toLowerCase().includes(searchQuery.value.toLowerCase()),
200
- ),
201
- )
202
-
203
- // Static palette maps — Tailwind's JIT can't see classes built from
204
- // template strings (`bg-${variant}-500`), so we expand each variant to a
205
- // concrete bundle of utilities that the compiler can find at build time.
206
- const LIGHT_PALETTES: Record<Variant, PaletteClasses> = {
207
- emerald: {
208
- iconBg: 'bg-emerald-50',
209
- iconText: 'text-emerald-600',
210
- inputFocus: 'focus:border-emerald-500',
211
- selectedRow: 'bg-emerald-50 border border-emerald-200',
212
- checkBg: 'bg-emerald-600',
213
- confirmActive: 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
214
- confirmDisabled: 'bg-emerald-400 cursor-not-allowed',
215
- },
216
- purple: {
217
- iconBg: 'bg-purple-50',
218
- iconText: 'text-purple-600',
219
- inputFocus: 'focus:border-purple-500',
220
- selectedRow: 'bg-purple-50 border border-purple-200',
221
- checkBg: 'bg-purple-600',
222
- confirmActive: 'bg-purple-600 hover:bg-purple-700 cursor-pointer',
223
- confirmDisabled: 'bg-purple-400 cursor-not-allowed',
224
- },
225
- blue: {
226
- iconBg: 'bg-blue-50',
227
- iconText: 'text-blue-600',
228
- inputFocus: 'focus:border-blue-500',
229
- selectedRow: 'bg-blue-50 border border-blue-200',
230
- checkBg: 'bg-blue-600',
231
- confirmActive: 'bg-blue-600 hover:bg-blue-700 cursor-pointer',
232
- confirmDisabled: 'bg-blue-400 cursor-not-allowed',
233
- },
234
- red: {
235
- iconBg: 'bg-red-50',
236
- iconText: 'text-red-600',
237
- inputFocus: 'focus:border-red-500',
238
- selectedRow: 'bg-red-50 border border-red-200',
239
- checkBg: 'bg-red-600',
240
- confirmActive: 'bg-red-600 hover:bg-red-700 cursor-pointer',
241
- confirmDisabled: 'bg-red-400 cursor-not-allowed',
242
- },
243
- amber: {
244
- iconBg: 'bg-amber-50',
245
- iconText: 'text-amber-600',
246
- inputFocus: 'focus:border-amber-500',
247
- selectedRow: 'bg-amber-50 border border-amber-200',
248
- checkBg: 'bg-amber-600',
249
- confirmActive: 'bg-amber-600 hover:bg-amber-700 cursor-pointer',
250
- confirmDisabled: 'bg-amber-400 cursor-not-allowed',
251
- },
252
- }
253
-
254
- const DARK_PALETTES: Record<Variant, PaletteClasses> = {
255
- emerald: {
256
- iconBg: 'bg-emerald-900/30',
257
- iconText: 'text-emerald-400',
258
- inputFocus: 'focus:border-emerald-500',
259
- selectedRow: 'bg-emerald-500/15 border border-emerald-500/30',
260
- checkBg: 'bg-emerald-600',
261
- confirmActive: 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
262
- confirmDisabled: 'bg-emerald-400 cursor-not-allowed',
263
- },
264
- purple: {
265
- iconBg: 'bg-purple-900/30',
266
- iconText: 'text-purple-400',
267
- inputFocus: 'focus:border-purple-500',
268
- selectedRow: 'bg-purple-500/15 border border-purple-500/30',
269
- checkBg: 'bg-purple-600',
270
- confirmActive: 'bg-purple-600 hover:bg-purple-700 cursor-pointer',
271
- confirmDisabled: 'bg-purple-400 cursor-not-allowed',
272
- },
273
- blue: {
274
- iconBg: 'bg-blue-900/30',
275
- iconText: 'text-blue-400',
276
- inputFocus: 'focus:border-blue-500',
277
- selectedRow: 'bg-blue-500/15 border border-blue-500/30',
278
- checkBg: 'bg-blue-600',
279
- confirmActive: 'bg-blue-600 hover:bg-blue-700 cursor-pointer',
280
- confirmDisabled: 'bg-blue-400 cursor-not-allowed',
281
- },
282
- red: {
283
- iconBg: 'bg-red-900/30',
284
- iconText: 'text-red-400',
285
- inputFocus: 'focus:border-red-500',
286
- selectedRow: 'bg-red-500/15 border border-red-500/30',
287
- checkBg: 'bg-red-600',
288
- confirmActive: 'bg-red-600 hover:bg-red-700 cursor-pointer',
289
- confirmDisabled: 'bg-red-400 cursor-not-allowed',
290
- },
291
- amber: {
292
- iconBg: 'bg-amber-900/30',
293
- iconText: 'text-amber-400',
294
- inputFocus: 'focus:border-amber-500',
295
- selectedRow: 'bg-amber-500/15 border border-amber-500/30',
296
- checkBg: 'bg-amber-600',
297
- confirmActive: 'bg-amber-600 hover:bg-amber-700 cursor-pointer',
298
- confirmDisabled: 'bg-amber-400 cursor-not-allowed',
299
- },
300
- }
301
-
302
- const palette = computed<PaletteClasses>(() =>
303
- (isDark.value ? DARK_PALETTES : LIGHT_PALETTES)[props.variant],
304
- )
305
-
306
- onMounted(async () => {
307
- if (props.loader) {
308
- loading.value = true
309
- try {
310
- loadedItems.value = await props.loader()
311
- } catch (_e) {
312
- loadError.value = 'Failed to load items'
313
- } finally {
314
- loading.value = false
315
- }
316
- }
317
- await nextTick()
318
- searchInputRef.value?.focus()
319
- })
320
-
321
- function handleConfirm() {
322
- if (!selectedId.value || props.submitting) return
323
- emit('confirm', selectedId.value)
324
- }
325
-
326
- function handleCancel() {
327
- if (props.submitting) return
328
- emit('cancel')
329
- }
330
- </script>
1
+ <template>
2
+ <BaseModalShell
3
+ :title="title"
4
+ :icon="FolderIcon"
5
+ :icon-bg-class="palette.iconBg"
6
+ :icon-class="palette.iconText"
7
+ :manual-close="submitting"
8
+ @cancel="handleCancel"
9
+ >
10
+ <!-- Forward a caller-supplied icon override; otherwise the shell
11
+ renders the default folder icon in the palette-tinted chip. -->
12
+ <template v-if="$slots.icon" #icon>
13
+ <slot name="icon" />
14
+ </template>
15
+
16
+ <p v-if="message" class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
17
+ {{ message }}
18
+ </p>
19
+
20
+ <!-- Loading -->
21
+ <div v-if="loading" class="mt-4 flex justify-center py-6">
22
+ <BaseSpinner color="emerald" />
23
+ </div>
24
+
25
+ <!-- Error -->
26
+ <div v-else-if="loadError" class="mt-4 text-sm text-red-500" role="alert">
27
+ {{ loadError }}
28
+ </div>
29
+
30
+ <!-- Search input -->
31
+ <div v-if="!loading && !loadError && items.length > 0" class="mt-4">
32
+ <div class="relative">
33
+ <MagnifyingGlassIcon
34
+ class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
35
+ :class="t.dimText"
36
+ aria-hidden="true"
37
+ />
38
+ <input
39
+ ref="searchInputRef"
40
+ v-model="searchQuery"
41
+ type="text"
42
+ :placeholder="searchPlaceholder"
43
+ class="w-full pl-9 pr-3 py-2 text-sm rounded-md border outline-none transition-colors"
44
+ :class="[
45
+ isDark
46
+ ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500'
47
+ : 'bg-white border-gray-300 text-gray-700 placeholder-gray-400',
48
+ palette.inputFocus,
49
+ ]"
50
+ />
51
+ </div>
52
+ </div>
53
+
54
+ <!-- Item list -->
55
+ <div v-if="!loading && !loadError" class="mt-3 max-h-64 overflow-y-auto">
56
+ <button
57
+ v-for="item in filteredItems"
58
+ :key="item.id"
59
+ type="button"
60
+ :aria-pressed="selectedId === item.id"
61
+ class="w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
62
+ :class="[
63
+ selectedId === item.id
64
+ ? palette.selectedRow
65
+ : isDark
66
+ ? 'hover:bg-gray-800 border border-transparent'
67
+ : 'hover:bg-gray-50 border border-transparent'
68
+ ]"
69
+ @click="selectedId = item.id"
70
+ >
71
+ <FolderIcon class="w-5 h-5 flex-shrink-0" :class="t.dimText" />
72
+ <div class="flex-1 min-w-0">
73
+ <p class="text-sm font-medium truncate" :class="t.bodyText">
74
+ {{ item.label }}
75
+ </p>
76
+ </div>
77
+ <div v-if="selectedId === item.id"
78
+ class="w-5 h-5 rounded-full flex items-center justify-center"
79
+ :class="palette.checkBg">
80
+ <svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" aria-hidden="true">
81
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
82
+ </svg>
83
+ </div>
84
+ </button>
85
+
86
+ <!-- Empty states -->
87
+ <div v-if="items.length === 0" class="text-center py-6">
88
+ <p class="text-sm" :class="t.dimText">
89
+ {{ emptyMessage }}
90
+ </p>
91
+ </div>
92
+ <div v-else-if="filteredItems.length === 0" class="text-center py-6">
93
+ <p class="text-sm" :class="t.dimText">
94
+ {{ noMatchMessage }}
95
+ </p>
96
+ </div>
97
+ </div>
98
+
99
+ <template #footer>
100
+ <button
101
+ type="button"
102
+ :disabled="submitting"
103
+ class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
104
+ :class="t.ghostButton"
105
+ @click="handleCancel"
106
+ >
107
+ {{ cancelText }}
108
+ </button>
109
+ <button
110
+ type="button"
111
+ :disabled="!selectedId || submitting"
112
+ class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
113
+ :class="!selectedId || submitting ? palette.confirmDisabled : palette.confirmActive"
114
+ @click="handleConfirm"
115
+ >
116
+ {{ submitting ? submittingText : confirmText }}
117
+ </button>
118
+ </template>
119
+ </BaseModalShell>
120
+ </template>
121
+
122
+ <script setup lang="ts">
123
+ import { ref, computed, onMounted, nextTick } from 'vue'
124
+ import { FolderIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
125
+ import BaseModalShell from './BaseModalShell.vue'
126
+ import BaseSpinner from './BaseSpinner.vue'
127
+ import { useTheme } from '../composables/useTheme'
128
+ import { useThemeClasses } from '../composables/useThemeClasses'
129
+ import type { EntityPickerItem } from '../types/entityPicker'
130
+
131
+ type Variant = 'emerald' | 'purple' | 'blue' | 'red' | 'amber'
132
+
133
+ interface PaletteClasses {
134
+ iconBg: string
135
+ iconText: string
136
+ inputFocus: string
137
+ selectedRow: string
138
+ checkBg: string
139
+ confirmActive: string
140
+ confirmDisabled: string
141
+ }
142
+
143
+ interface Props {
144
+ title: string
145
+ message?: string
146
+ /** Pre-loaded items. Pass these instead of `loader` when the items are already in memory. */
147
+ items?: EntityPickerItem[]
148
+ /** Async loader called on mount. Use when items live in a backend service. */
149
+ loader?: () => Promise<EntityPickerItem[]>
150
+ /** ID to exclude from the list (e.g. the current entity). */
151
+ excludeId?: string
152
+ /** Accent colour for selection / confirm. Defaults to 'emerald'. */
153
+ variant?: Variant
154
+ searchPlaceholder?: string
155
+ emptyMessage?: string
156
+ noMatchMessage?: string
157
+ confirmText?: string
158
+ cancelText?: string
159
+ submittingText?: string
160
+ submitting?: boolean
161
+ }
162
+
163
+ const props = withDefaults(defineProps<Props>(), {
164
+ items: () => [],
165
+ variant: 'emerald',
166
+ searchPlaceholder: 'Search...',
167
+ emptyMessage: 'No items available.',
168
+ noMatchMessage: 'No items match your search.',
169
+ confirmText: 'Select',
170
+ cancelText: 'Cancel',
171
+ submittingText: 'Working...',
172
+ submitting: false,
173
+ })
174
+
175
+ const emit = defineEmits<{
176
+ confirm: [itemId: string]
177
+ cancel: []
178
+ }>()
179
+
180
+ const { isDark } = useTheme()
181
+ const t = useThemeClasses()
182
+
183
+ const loadedItems = ref<EntityPickerItem[]>([])
184
+ const loading = ref(false)
185
+ const loadError = ref('')
186
+ const selectedId = ref<string | null>(null)
187
+ const searchQuery = ref('')
188
+ const searchInputRef = ref<HTMLInputElement | null>(null)
189
+
190
+ const items = computed<EntityPickerItem[]>(() => {
191
+ const source = props.loader ? loadedItems.value : props.items
192
+ return props.excludeId
193
+ ? source.filter(i => i.id !== props.excludeId)
194
+ : source
195
+ })
196
+
197
+ const filteredItems = computed(() =>
198
+ items.value.filter(i =>
199
+ i.label.toLowerCase().includes(searchQuery.value.toLowerCase()),
200
+ ),
201
+ )
202
+
203
+ // Static palette maps — Tailwind's JIT can't see classes built from
204
+ // template strings (`bg-${variant}-500`), so we expand each variant to a
205
+ // concrete bundle of utilities that the compiler can find at build time.
206
+ const LIGHT_PALETTES: Record<Variant, PaletteClasses> = {
207
+ emerald: {
208
+ iconBg: 'bg-emerald-50',
209
+ iconText: 'text-emerald-600',
210
+ inputFocus: 'focus:border-emerald-500',
211
+ selectedRow: 'bg-emerald-50 border border-emerald-200',
212
+ checkBg: 'bg-emerald-600',
213
+ confirmActive: 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
214
+ confirmDisabled: 'bg-emerald-400 cursor-not-allowed',
215
+ },
216
+ purple: {
217
+ iconBg: 'bg-purple-50',
218
+ iconText: 'text-purple-600',
219
+ inputFocus: 'focus:border-purple-500',
220
+ selectedRow: 'bg-purple-50 border border-purple-200',
221
+ checkBg: 'bg-purple-600',
222
+ confirmActive: 'bg-purple-600 hover:bg-purple-700 cursor-pointer',
223
+ confirmDisabled: 'bg-purple-400 cursor-not-allowed',
224
+ },
225
+ blue: {
226
+ iconBg: 'bg-blue-50',
227
+ iconText: 'text-blue-600',
228
+ inputFocus: 'focus:border-blue-500',
229
+ selectedRow: 'bg-blue-50 border border-blue-200',
230
+ checkBg: 'bg-blue-600',
231
+ confirmActive: 'bg-blue-600 hover:bg-blue-700 cursor-pointer',
232
+ confirmDisabled: 'bg-blue-400 cursor-not-allowed',
233
+ },
234
+ red: {
235
+ iconBg: 'bg-red-50',
236
+ iconText: 'text-red-600',
237
+ inputFocus: 'focus:border-red-500',
238
+ selectedRow: 'bg-red-50 border border-red-200',
239
+ checkBg: 'bg-red-600',
240
+ confirmActive: 'bg-red-600 hover:bg-red-700 cursor-pointer',
241
+ confirmDisabled: 'bg-red-400 cursor-not-allowed',
242
+ },
243
+ amber: {
244
+ iconBg: 'bg-amber-50',
245
+ iconText: 'text-amber-600',
246
+ inputFocus: 'focus:border-amber-500',
247
+ selectedRow: 'bg-amber-50 border border-amber-200',
248
+ checkBg: 'bg-amber-600',
249
+ confirmActive: 'bg-amber-600 hover:bg-amber-700 cursor-pointer',
250
+ confirmDisabled: 'bg-amber-400 cursor-not-allowed',
251
+ },
252
+ }
253
+
254
+ const DARK_PALETTES: Record<Variant, PaletteClasses> = {
255
+ emerald: {
256
+ iconBg: 'bg-emerald-900/30',
257
+ iconText: 'text-emerald-400',
258
+ inputFocus: 'focus:border-emerald-500',
259
+ selectedRow: 'bg-emerald-500/15 border border-emerald-500/30',
260
+ checkBg: 'bg-emerald-600',
261
+ confirmActive: 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
262
+ confirmDisabled: 'bg-emerald-400 cursor-not-allowed',
263
+ },
264
+ purple: {
265
+ iconBg: 'bg-purple-900/30',
266
+ iconText: 'text-purple-400',
267
+ inputFocus: 'focus:border-purple-500',
268
+ selectedRow: 'bg-purple-500/15 border border-purple-500/30',
269
+ checkBg: 'bg-purple-600',
270
+ confirmActive: 'bg-purple-600 hover:bg-purple-700 cursor-pointer',
271
+ confirmDisabled: 'bg-purple-400 cursor-not-allowed',
272
+ },
273
+ blue: {
274
+ iconBg: 'bg-blue-900/30',
275
+ iconText: 'text-blue-400',
276
+ inputFocus: 'focus:border-blue-500',
277
+ selectedRow: 'bg-blue-500/15 border border-blue-500/30',
278
+ checkBg: 'bg-blue-600',
279
+ confirmActive: 'bg-blue-600 hover:bg-blue-700 cursor-pointer',
280
+ confirmDisabled: 'bg-blue-400 cursor-not-allowed',
281
+ },
282
+ red: {
283
+ iconBg: 'bg-red-900/30',
284
+ iconText: 'text-red-400',
285
+ inputFocus: 'focus:border-red-500',
286
+ selectedRow: 'bg-red-500/15 border border-red-500/30',
287
+ checkBg: 'bg-red-600',
288
+ confirmActive: 'bg-red-600 hover:bg-red-700 cursor-pointer',
289
+ confirmDisabled: 'bg-red-400 cursor-not-allowed',
290
+ },
291
+ amber: {
292
+ iconBg: 'bg-amber-900/30',
293
+ iconText: 'text-amber-400',
294
+ inputFocus: 'focus:border-amber-500',
295
+ selectedRow: 'bg-amber-500/15 border border-amber-500/30',
296
+ checkBg: 'bg-amber-600',
297
+ confirmActive: 'bg-amber-600 hover:bg-amber-700 cursor-pointer',
298
+ confirmDisabled: 'bg-amber-400 cursor-not-allowed',
299
+ },
300
+ }
301
+
302
+ const palette = computed<PaletteClasses>(() =>
303
+ (isDark.value ? DARK_PALETTES : LIGHT_PALETTES)[props.variant],
304
+ )
305
+
306
+ onMounted(async () => {
307
+ if (props.loader) {
308
+ loading.value = true
309
+ try {
310
+ loadedItems.value = await props.loader()
311
+ } catch (_e) {
312
+ loadError.value = 'Failed to load items'
313
+ } finally {
314
+ loading.value = false
315
+ }
316
+ }
317
+ await nextTick()
318
+ searchInputRef.value?.focus()
319
+ })
320
+
321
+ function handleConfirm() {
322
+ if (!selectedId.value || props.submitting) return
323
+ emit('confirm', selectedId.value)
324
+ }
325
+
326
+ function handleCancel() {
327
+ if (props.submitting) return
328
+ emit('cancel')
329
+ }
330
+ </script>