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.
- package/README.md +1794 -1719
- package/dist/components/BaseBadge.vue.d.ts +1 -11
- package/dist/components/BaseDropdown.vue.d.ts +1 -1
- package/dist/ui-lib.js +942 -925
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/specForm.d.ts +1 -1
- package/package.json +54 -54
- package/src/components/BaseAlert.vue +104 -104
- package/src/components/BaseBadge.vue +12 -2
- package/src/components/BaseBarDistribution.test.ts +51 -51
- package/src/components/BaseBarDistribution.vue +73 -73
- package/src/components/BaseChipButton.vue +42 -42
- package/src/components/BaseCodeBlock.test.ts +70 -70
- package/src/components/BaseCodeBlock.vue +74 -74
- package/src/components/BaseCollapsibleSection.vue +113 -113
- package/src/components/BaseConfirmModal.vue +114 -114
- package/src/components/BaseCredentialsForm.test.ts +108 -108
- package/src/components/BaseCredentialsForm.vue +241 -241
- package/src/components/BaseDropdown.test.ts +87 -87
- package/src/components/BaseDropdown.vue +172 -172
- package/src/components/BaseEntityPickerModal.vue +330 -330
- package/src/components/BaseFileDropzone.vue +94 -94
- package/src/components/BaseFilterChip.test.ts +44 -44
- package/src/components/BaseFilterChip.vue +67 -67
- package/src/components/BaseModalShell.vue +209 -209
- package/src/components/BasePageHeader.vue +120 -120
- package/src/components/BasePillPickerModal.test.ts +95 -95
- package/src/components/BaseRemoveButton.vue +22 -22
- package/src/components/BaseSpecFields.test.ts +39 -0
- package/src/components/BaseSpecFields.vue +27 -3
- package/src/components/BaseStatBreakdown.test.ts +56 -56
- package/src/components/BaseStatBreakdown.vue +49 -49
- package/src/components/BaseStatusPill.vue +54 -54
- package/src/components/BaseTextInputModal.vue +140 -140
- package/src/components/BaseToolbarButton.test.ts +48 -48
- package/src/components/BaseToolbarButton.vue +94 -94
- package/src/composables/useFieldClasses.test.ts +55 -55
- package/src/composables/useFieldClasses.ts +56 -56
- package/src/composables/usePolling.test.ts +112 -112
- package/src/composables/usePolling.ts +102 -102
- package/src/composables/useQueryParamSync.test.ts +56 -56
- package/src/composables/useQueryParamSync.ts +42 -42
- package/src/composables/useThemeClasses.test.ts +34 -34
- package/src/composables/useThemeClasses.ts +137 -137
- package/src/index.ts +138 -138
- package/src/style.css +32 -32
- package/src/types/distribution.ts +7 -7
- package/src/types/pillPicker.ts +14 -14
- package/src/types/specField.ts +30 -30
- package/src/types/statBreakdown.ts +6 -6
- package/src/utils/format.test.ts +264 -264
- package/src/utils/format.ts +216 -216
- package/src/utils/httpColors.test.ts +100 -100
- package/src/utils/httpColors.ts +99 -99
- package/src/utils/kvRows.test.ts +47 -47
- package/src/utils/kvRows.ts +34 -34
- package/src/utils/specForm.test.ts +64 -53
- package/src/utils/specForm.ts +49 -48
- package/src/utils/validate.test.ts +82 -82
- 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>
|