mgv-backoffice 1.37.0 → 1.38.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.
@@ -0,0 +1,318 @@
1
+ <template>
2
+ <!--
3
+ Searchable select ("combobox"). Type to filter the options, pick with the
4
+ mouse or ArrowUp/ArrowDown + Enter. `multiple` turns it into a tag picker:
5
+ picked options show as removable chips and the menu stays open so several
6
+ can be picked in a row (Backspace on an empty search removes the last one).
7
+
8
+ Options are data ({ value, label, title?, disabled? } — the same shape as
9
+ BaseDropdown), and the field skin matches BaseInput/BaseSelect so it can sit
10
+ in the same filter row. Escape and an outside click close the menu.
11
+ -->
12
+ <div ref="rootEl" :class="block ? 'relative w-full' : 'relative inline-block min-w-48'">
13
+ <div
14
+ class="flex min-h-[38px] items-center gap-1.5 rounded-lg border bg-white px-3 py-1.5 text-sm transition-colors dark:bg-slate-900"
15
+ :class="[
16
+ open
17
+ ? 'border-emerald-500 ring-2 ring-emerald-500/30'
18
+ : 'border-slate-300 hover:border-slate-400 dark:border-slate-600 dark:hover:border-slate-500',
19
+ disabled ? 'cursor-not-allowed opacity-60' : 'cursor-text',
20
+ ]"
21
+ @mousedown="onControlMouseDown"
22
+ >
23
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-1">
24
+ <span
25
+ v-for="opt in selectedOptions"
26
+ v-show="multiple"
27
+ :key="String(opt.value)"
28
+ class="inline-flex max-w-full items-center gap-1 rounded-md bg-emerald-50 py-0.5 pl-2 pr-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
29
+ >
30
+ <span class="truncate">{{ opt.label }}</span>
31
+ <button
32
+ type="button"
33
+ class="rounded p-0.5 hover:bg-emerald-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 dark:hover:bg-emerald-500/25"
34
+ :aria-label="`Remove ${opt.label}`"
35
+ :disabled="disabled"
36
+ @mousedown.stop.prevent
37
+ @click.stop="remove(opt)"
38
+ >
39
+ <XMarkIcon class="h-3 w-3" aria-hidden="true" />
40
+ </button>
41
+ </span>
42
+ <input
43
+ ref="inputEl"
44
+ v-model="query"
45
+ type="text"
46
+ role="combobox"
47
+ autocomplete="off"
48
+ :disabled="disabled"
49
+ :readonly="!searchable"
50
+ :aria-label="ariaLabel || placeholder"
51
+ aria-autocomplete="list"
52
+ :aria-expanded="open"
53
+ :aria-controls="listId"
54
+ :aria-activedescendant="open && activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined"
55
+ :placeholder="inputPlaceholder"
56
+ class="min-w-16 flex-1 border-0 bg-transparent p-0 py-0.5 text-sm text-slate-900 outline-none focus:ring-0 dark:text-slate-100 disabled:cursor-not-allowed"
57
+ :class="hasSingleValue
58
+ ? 'placeholder:text-slate-900 dark:placeholder:text-slate-100'
59
+ : 'placeholder:text-slate-400 dark:placeholder:text-slate-500'"
60
+ @focus="openMenu"
61
+ @input="openMenu"
62
+ @keydown="onKeydown"
63
+ />
64
+ </div>
65
+ <button
66
+ v-if="clearable && hasValue && !disabled"
67
+ type="button"
68
+ class="shrink-0 rounded p-0.5 text-slate-400 hover:text-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 dark:hover:text-slate-200"
69
+ aria-label="Clear selection"
70
+ @mousedown.stop.prevent
71
+ @click.stop="clear"
72
+ >
73
+ <XMarkIcon class="h-4 w-4" aria-hidden="true" />
74
+ </button>
75
+ <ChevronDownIcon
76
+ class="h-4 w-4 shrink-0 text-slate-500 transition-transform dark:text-slate-400"
77
+ :class="open ? 'rotate-180' : ''"
78
+ aria-hidden="true"
79
+ />
80
+ </div>
81
+
82
+ <ul
83
+ v-if="open"
84
+ :id="listId"
85
+ role="listbox"
86
+ :aria-multiselectable="multiple || undefined"
87
+ class="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-900"
88
+ >
89
+ <li
90
+ v-for="(opt, i) in filtered"
91
+ :id="`${listId}-${i}`"
92
+ :key="String(opt.value)"
93
+ role="option"
94
+ :aria-selected="isSelected(opt)"
95
+ :aria-disabled="opt.disabled || undefined"
96
+ :title="opt.title"
97
+ class="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-sm"
98
+ :class="rowClass(opt, i)"
99
+ @mousedown.prevent
100
+ @mouseenter="activeIndex = i"
101
+ @click="pick(opt)"
102
+ >
103
+ <span class="truncate">{{ opt.label }}</span>
104
+ <CheckIcon v-if="isSelected(opt)" class="h-4 w-4 shrink-0" aria-hidden="true" />
105
+ </li>
106
+ <li v-if="filtered.length === 0" class="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
107
+ {{ options.length === 0 ? emptyText : noResultsText }}
108
+ </li>
109
+ </ul>
110
+ </div>
111
+ </template>
112
+
113
+ <script setup lang="ts">
114
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
115
+ import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/vue/24/outline'
116
+ import type { DropdownOption } from '../types/dropdown'
117
+
118
+ type Value = string | number
119
+
120
+ interface Props {
121
+ options: readonly DropdownOption[]
122
+ /** Selected value (single) or values (`multiple`). */
123
+ modelValue?: Value | null | readonly Value[]
124
+ /** Pick several options, shown as removable chips. */
125
+ multiple?: boolean
126
+ placeholder?: string
127
+ /** Allow typing to filter. False makes it a plain (non-typing) picker. */
128
+ searchable?: boolean
129
+ /** Show the clear (×) button while something is selected. */
130
+ clearable?: boolean
131
+ /** Full-width (w-full). Set false for an inline field. */
132
+ block?: boolean
133
+ disabled?: boolean
134
+ /** Accessible name when there is no visible label. */
135
+ ariaLabel?: string
136
+ /** Shown when the search matches nothing. */
137
+ noResultsText?: string
138
+ /** Shown when there are no options at all. */
139
+ emptyText?: string
140
+ }
141
+
142
+ const props = withDefaults(defineProps<Props>(), {
143
+ modelValue: null,
144
+ multiple: false,
145
+ placeholder: 'Select',
146
+ searchable: true,
147
+ clearable: true,
148
+ block: true,
149
+ disabled: false,
150
+ ariaLabel: '',
151
+ noResultsText: 'No matches.',
152
+ emptyText: 'No options.',
153
+ })
154
+
155
+ const emit = defineEmits<{
156
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
+ (e: 'update:modelValue', value: any): void
158
+ }>()
159
+
160
+ const rootEl = ref<HTMLElement | null>(null)
161
+ const inputEl = ref<HTMLInputElement | null>(null)
162
+ const open = ref(false)
163
+ const query = ref('')
164
+ const activeIndex = ref(-1)
165
+ const listId = `search-select-${useId()}`
166
+
167
+ const selectedValues = computed<Value[]>(() => {
168
+ const v = props.modelValue
169
+ if (Array.isArray(v)) return [...v]
170
+ return v === null || v === undefined || v === '' ? [] : [v as Value]
171
+ })
172
+
173
+ const selectedOptions = computed(() =>
174
+ selectedValues.value
175
+ .map((v) => props.options.find((o) => o.value === v))
176
+ .filter((o): o is DropdownOption => !!o),
177
+ )
178
+
179
+ const hasValue = computed(() => selectedValues.value.length > 0)
180
+ const hasSingleValue = computed(() => !props.multiple && selectedOptions.value.length > 0)
181
+
182
+ // Single mode shows the picked label as the input's placeholder, so typing
183
+ // starts a fresh search while the current choice stays visible until then.
184
+ const inputPlaceholder = computed(() => {
185
+ if (hasSingleValue.value) return selectedOptions.value[0].label
186
+ if (props.multiple && hasValue.value) return ''
187
+ return props.placeholder
188
+ })
189
+
190
+ const filtered = computed(() => {
191
+ const q = query.value.trim().toLowerCase()
192
+ return q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : [...props.options]
193
+ })
194
+
195
+ watch(filtered, (list) => {
196
+ activeIndex.value = list.findIndex((o) => !o.disabled)
197
+ })
198
+
199
+ function isSelected(opt: DropdownOption): boolean {
200
+ return selectedValues.value.includes(opt.value)
201
+ }
202
+
203
+ function rowClass(opt: DropdownOption, i: number): string {
204
+ if (opt.disabled) return 'cursor-not-allowed text-slate-400 dark:text-slate-600'
205
+ if (isSelected(opt)) {
206
+ return i === activeIndex.value
207
+ ? 'bg-emerald-600 text-white'
208
+ : 'bg-emerald-500 text-white'
209
+ }
210
+ return i === activeIndex.value
211
+ ? 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
212
+ : 'text-slate-700 dark:text-slate-200'
213
+ }
214
+
215
+ function openMenu() {
216
+ if (props.disabled || open.value) return
217
+ open.value = true
218
+ activeIndex.value = filtered.value.findIndex((o) => !o.disabled)
219
+ }
220
+
221
+ function closeMenu() {
222
+ open.value = false
223
+ query.value = ''
224
+ }
225
+
226
+ function onControlMouseDown(e: MouseEvent) {
227
+ if (props.disabled) return
228
+ // Keep focus in the input; toggle the menu when clicking the chrome.
229
+ if (e.target !== inputEl.value) e.preventDefault()
230
+ if (open.value && e.target !== inputEl.value) {
231
+ closeMenu()
232
+ } else {
233
+ inputEl.value?.focus()
234
+ openMenu()
235
+ }
236
+ }
237
+
238
+ function pick(opt: DropdownOption) {
239
+ if (opt.disabled) return
240
+ if (props.multiple) {
241
+ const next = isSelected(opt)
242
+ ? selectedValues.value.filter((v) => v !== opt.value)
243
+ : [...selectedValues.value, opt.value]
244
+ emit('update:modelValue', next)
245
+ query.value = ''
246
+ nextTick(() => inputEl.value?.focus())
247
+ } else {
248
+ emit('update:modelValue', opt.value)
249
+ closeMenu()
250
+ inputEl.value?.blur()
251
+ }
252
+ }
253
+
254
+ function remove(opt: DropdownOption) {
255
+ emit('update:modelValue', selectedValues.value.filter((v) => v !== opt.value))
256
+ }
257
+
258
+ function clear() {
259
+ emit('update:modelValue', props.multiple ? [] : null)
260
+ query.value = ''
261
+ }
262
+
263
+ function move(step: 1 | -1) {
264
+ const list = filtered.value
265
+ if (list.length === 0) return
266
+ let i = activeIndex.value
267
+ for (let n = 0; n < list.length; n++) {
268
+ i = (i + step + list.length) % list.length
269
+ if (!list[i].disabled) break
270
+ }
271
+ activeIndex.value = i
272
+ nextTick(() => document.getElementById(`${listId}-${i}`)?.scrollIntoView?.({ block: 'nearest' }))
273
+ }
274
+
275
+ function onKeydown(e: KeyboardEvent) {
276
+ switch (e.key) {
277
+ case 'ArrowDown':
278
+ e.preventDefault()
279
+ if (!open.value) openMenu()
280
+ else move(1)
281
+ break
282
+ case 'ArrowUp':
283
+ e.preventDefault()
284
+ if (!open.value) openMenu()
285
+ else move(-1)
286
+ break
287
+ case 'Enter':
288
+ if (open.value && activeIndex.value >= 0) {
289
+ e.preventDefault()
290
+ pick(filtered.value[activeIndex.value])
291
+ }
292
+ break
293
+ case 'Escape':
294
+ if (open.value) {
295
+ e.preventDefault()
296
+ closeMenu()
297
+ }
298
+ break
299
+ case 'Tab':
300
+ closeMenu()
301
+ break
302
+ case 'Backspace':
303
+ if (props.multiple && query.value === '' && selectedOptions.value.length) {
304
+ remove(selectedOptions.value[selectedOptions.value.length - 1])
305
+ }
306
+ break
307
+ }
308
+ }
309
+
310
+ function onDocumentMouseDown(e: MouseEvent) {
311
+ if (open.value && rootEl.value && !rootEl.value.contains(e.target as Node)) {
312
+ closeMenu()
313
+ }
314
+ }
315
+
316
+ onMounted(() => document.addEventListener('mousedown', onDocumentMouseDown))
317
+ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocumentMouseDown))
318
+ </script>
@@ -11,15 +11,15 @@ describe('BaseSelect', () => {
11
11
  const wrapper = mount(BaseSelect, { slots })
12
12
  const select = wrapper.find('select')
13
13
  expect(select.classes()).toEqual(
14
- expect.arrayContaining(['rounded', 'border', 'w-full', 'px-2', 'py-1.5', 'text-sm']),
14
+ expect.arrayContaining(['rounded-lg', 'border', 'w-full', 'pl-2.5', 'py-1.5', 'text-sm']),
15
15
  )
16
16
  })
17
17
 
18
18
  it('supports md size and inline (non-block) layout', () => {
19
19
  const wrapper = mount(BaseSelect, { props: { size: 'md', block: false }, slots })
20
20
  const select = wrapper.find('select')
21
- expect(select.classes()).toEqual(expect.arrayContaining(['px-3', 'py-2']))
22
- expect(select.classes()).not.toContain('w-full')
21
+ expect(select.classes()).toEqual(expect.arrayContaining(['pl-3', 'py-2']))
22
+ expect(wrapper.classes()).toContain('inline-block')
23
23
  })
24
24
 
25
25
  it('reflects modelValue and emits the selected value on change', async () => {
@@ -1,46 +1,61 @@
1
- <template>
2
- <!--
3
- Themed select sharing BaseInput's field skin. Options come from the
4
- default slot so callers keep full control of <option> rendering (values,
5
- titles, disabled entries). Attrs (id, disabled, extra classes) fall
6
- through. Same `dark:`-variant note as BaseInput.
7
- -->
8
- <select
9
- :value="modelValue"
10
- class="rounded border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
11
- :class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
12
- @change="onChange"
13
- >
14
- <slot />
15
- </select>
16
- </template>
17
-
18
- <script setup lang="ts">
19
- // String-valued only — a DOM select always yields strings; use v-model.number
20
- // semantics in the owner if needed. The update:modelValue payload is `any`
21
- // (not generic: vite-plugin-dts flattens SFC generics out of the emitted
22
- // declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
23
- // type-checks; the actual payload is always the selected option's string.
24
- interface Props {
25
- modelValue?: string | null
26
- /** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
27
- size?: 'sm' | 'md'
28
- /** Full-width (w-full). Set false for inline selects. */
29
- block?: boolean
30
- }
31
-
32
- withDefaults(defineProps<Props>(), {
33
- modelValue: undefined,
34
- size: 'sm',
35
- block: true,
36
- })
37
-
38
- const emit = defineEmits<{
39
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
- (e: 'update:modelValue', value: any): void
41
- }>()
42
-
43
- function onChange(e: Event) {
44
- emit('update:modelValue', (e.target as HTMLSelectElement).value)
45
- }
46
- </script>
1
+ <template>
2
+ <div class="relative" :class="block ? 'w-full' : 'inline-block'">
3
+ <select
4
+ v-bind="$attrs"
5
+ :value="modelValue"
6
+ class="w-full appearance-none rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
7
+ :class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
8
+ @change="onChange"
9
+ >
10
+ <slot />
11
+ </select>
12
+ <ChevronDownIcon
13
+ class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 dark:text-slate-400"
14
+ :class="size === 'md' ? 'right-3' : 'right-2.5'"
15
+ aria-hidden="true"
16
+ />
17
+ </div>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ /*
22
+ * Themed select sharing BaseInput's field skin. Options come from the
23
+ * default slot so callers keep full control of <option> rendering (values,
24
+ * titles, disabled entries). Attrs (id, disabled, extra classes) fall
25
+ * through. Same `dark:`-variant note as BaseInput.
26
+ *
27
+ */
28
+ import { ChevronDownIcon } from '@heroicons/vue/24/outline'
29
+
30
+ // Attrs (id, name, disabled, extra classes) go to the <select>, not the
31
+ // chevron wrapper.
32
+ defineOptions({ inheritAttrs: false })
33
+
34
+ // String-valued only — a DOM select always yields strings; use v-model.number
35
+ // semantics in the owner if needed. The update:modelValue payload is `any`
36
+ // (not generic: vite-plugin-dts flattens SFC generics out of the emitted
37
+ // declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
38
+ // type-checks; the actual payload is always the selected option's string.
39
+ interface Props {
40
+ modelValue?: string | null
41
+ /** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
42
+ size?: 'sm' | 'md'
43
+ /** Full-width (w-full). Set false for inline selects. */
44
+ block?: boolean
45
+ }
46
+
47
+ withDefaults(defineProps<Props>(), {
48
+ modelValue: undefined,
49
+ size: 'sm',
50
+ block: true,
51
+ })
52
+
53
+ const emit = defineEmits<{
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ (e: 'update:modelValue', value: any): void
56
+ }>()
57
+
58
+ function onChange(e: Event) {
59
+ emit('update:modelValue', (e.target as HTMLSelectElement).value)
60
+ }
61
+ </script>
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, RouterLinkStub } from '@vue/test-utils'
3
+ import BaseStatCard from './BaseStatCard.vue'
4
+
5
+ const global = { stubs: { RouterLink: RouterLinkStub } }
6
+
7
+ describe('BaseStatCard', () => {
8
+ it('renders title, value and subtitle with the dashed accent border', () => {
9
+ const wrapper = mount(BaseStatCard, {
10
+ props: { title: 'Categories', value: 71, subtitle: 'in total', accent: 'sky' },
11
+ global,
12
+ })
13
+ expect(wrapper.text()).toContain('Categories')
14
+ expect(wrapper.text()).toContain('71')
15
+ expect(wrapper.text()).toContain('in total')
16
+ expect(wrapper.classes()).toEqual(expect.arrayContaining(['border-dashed', 'border-sky-300']))
17
+ })
18
+
19
+ it('is a plain div without `to` and a router link with it', () => {
20
+ expect(mount(BaseStatCard, { props: { title: 'A' }, global }).element.tagName).toBe('DIV')
21
+ const link = mount(BaseStatCard, { props: { title: 'A', to: '/category' }, global })
22
+ expect(link.findComponent(RouterLinkStub).props('to')).toBe('/category')
23
+ })
24
+
25
+ it('renders the icon chip only when the icon slot is filled', () => {
26
+ const plain = mount(BaseStatCard, { props: { title: 'A' }, global })
27
+ expect(plain.find('.rounded-xl .rounded-xl').exists()).toBe(false)
28
+ const withIcon = mount(BaseStatCard, {
29
+ props: { title: 'A', accent: 'red' },
30
+ slots: { icon: '<template #icon="{ iconClass }"><i :class="iconClass" /></template>' },
31
+ global,
32
+ })
33
+ expect(withIcon.find('i').classes()).toEqual(expect.arrayContaining(['h-6', 'w-6', 'text-red-500']))
34
+ })
35
+
36
+ it('omits the subtitle line when empty', () => {
37
+ const wrapper = mount(BaseStatCard, { props: { title: 'A', value: 1 }, global })
38
+ expect(wrapper.findAll('p')).toHaveLength(2)
39
+ })
40
+ })
@@ -0,0 +1,103 @@
1
+ <template>
2
+ <component
3
+ :is="to ? RouterLink : 'div'"
4
+ v-bind="to ? { to } : {}"
5
+ class="relative block rounded-xl border-2 border-dashed bg-white p-5 transition-colors dark:bg-slate-800"
6
+ :class="[cls.border, to ? 'hover:bg-slate-50 dark:hover:bg-slate-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : '']"
7
+ >
8
+ <div class="flex items-start justify-between gap-3">
9
+ <div class="min-w-0 text-left">
10
+ <p class="truncate text-sm font-bold tracking-wide text-gray-800 dark:text-slate-200">
11
+ {{ title }}
12
+ </p>
13
+ <p class="mt-1 truncate text-3xl font-bold" :class="cls.value">
14
+ {{ value }}
15
+ </p>
16
+ <p v-if="subtitle" class="mt-1 text-sm font-medium" :class="cls.subtitle">
17
+ {{ subtitle }}
18
+ </p>
19
+ <slot />
20
+ </div>
21
+ <div
22
+ v-if="$slots.icon"
23
+ class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
24
+ :class="cls.iconBox"
25
+ >
26
+ <slot name="icon" :icon-class="`h-6 w-6 ${cls.icon}`" />
27
+ </div>
28
+ </div>
29
+ </component>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ /**
34
+ * Dashboard stat tile in the EarningsCard style (dashed accent border,
35
+ * bold title, big value, accent subtitle, tinted icon chip) but for any
36
+ * value — counts, labels, pre-formatted numbers. Pass `to` to make the whole
37
+ * card a router link. The `icon` slot fills the chip (receives `iconClass`);
38
+ * the `default` slot adds extra content under the subtitle.
39
+ *
40
+ */
41
+ import { computed } from 'vue'
42
+ import { RouterLink, type RouteLocationRaw } from 'vue-router'
43
+
44
+ type Accent = 'emerald' | 'sky' | 'amber' | 'red' | 'slate'
45
+
46
+ interface Props {
47
+ title: string
48
+ value?: string | number
49
+ subtitle?: string
50
+ /** Border / value / subtitle / icon tint. */
51
+ accent?: Accent
52
+ /** Makes the card a router link. */
53
+ to?: RouteLocationRaw
54
+ }
55
+
56
+ const props = withDefaults(defineProps<Props>(), {
57
+ value: '',
58
+ subtitle: '',
59
+ accent: 'emerald',
60
+ to: undefined,
61
+ })
62
+
63
+ // Full literal class strings per accent so Tailwind can see every class.
64
+ const ACCENTS: Record<Accent, { border: string; value: string; subtitle: string; iconBox: string; icon: string }> = {
65
+ emerald: {
66
+ border: 'border-emerald-300 dark:border-emerald-700',
67
+ value: 'text-emerald-500 dark:text-emerald-400',
68
+ subtitle: 'text-emerald-500 dark:text-emerald-400',
69
+ iconBox: 'bg-emerald-50 dark:bg-emerald-900/30',
70
+ icon: 'text-emerald-500',
71
+ },
72
+ sky: {
73
+ border: 'border-sky-300 dark:border-sky-700',
74
+ value: 'text-sky-500 dark:text-sky-400',
75
+ subtitle: 'text-sky-500 dark:text-sky-400',
76
+ iconBox: 'bg-sky-50 dark:bg-sky-900/30',
77
+ icon: 'text-sky-500',
78
+ },
79
+ amber: {
80
+ border: 'border-amber-300 dark:border-amber-700',
81
+ value: 'text-amber-500 dark:text-amber-400',
82
+ subtitle: 'text-amber-500 dark:text-amber-400',
83
+ iconBox: 'bg-amber-50 dark:bg-amber-900/30',
84
+ icon: 'text-amber-500',
85
+ },
86
+ red: {
87
+ border: 'border-red-300 dark:border-red-700',
88
+ value: 'text-red-500 dark:text-red-400',
89
+ subtitle: 'text-red-500 dark:text-red-400',
90
+ iconBox: 'bg-red-50 dark:bg-red-900/30',
91
+ icon: 'text-red-500',
92
+ },
93
+ slate: {
94
+ border: 'border-slate-300 dark:border-slate-600',
95
+ value: 'text-slate-700 dark:text-slate-200',
96
+ subtitle: 'text-slate-500 dark:text-slate-400',
97
+ iconBox: 'bg-slate-100 dark:bg-slate-700/50',
98
+ icon: 'text-slate-500 dark:text-slate-300',
99
+ },
100
+ }
101
+
102
+ const cls = computed(() => ACCENTS[props.accent])
103
+ </script>