mgv-backoffice 1.16.0 → 1.18.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.
- package/README.md +19 -7
- package/dist/src/components/BaseButton.vue.d.ts +0 -16
- package/dist/src/components/BaseConfirmModal.vue.d.ts +1 -0
- package/dist/src/components/BaseModal.vue.d.ts +16 -1
- package/dist/src/composables/useTheme.d.ts +7 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +417 -391
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +5 -3
- package/src/components/BaseAlert.vue +58 -33
- package/src/components/BaseBadge.vue +1 -0
- package/src/components/BaseButton.vue +1 -9
- package/src/components/BaseConfirmModal.vue +11 -3
- package/src/components/BaseModal.vue +35 -31
- package/src/components/BasePageHeader.vue +8 -2
- package/src/components/BaseRow.vue +1 -1
- package/src/components/BaseSidebar.vue +1 -9
- package/src/components/BaseToast.vue +3 -6
- package/src/components/ColoredSquares.vue +3 -2
- package/src/components/Pagination.test.ts +85 -0
- package/src/components/Pagination.vue +44 -99
- package/src/components/TrendArrow.vue +8 -1
- package/src/composables/useTheme.ts +23 -1
- package/src/index.ts +1 -1
- package/tailwind.safelist.css +43 -123
- package/tailwind.safelist.js +35 -3
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
2
|
+
<!--
|
|
3
|
+
@deprecated Legacy modal. Prefer BaseConfirmModal (confirm/cancel
|
|
4
|
+
flows) or BaseModalShell (custom dialogs) — they support dark mode,
|
|
5
|
+
teleport to <body>, and slot-based composition. Kept for backward
|
|
6
|
+
compatibility.
|
|
7
|
+
-->
|
|
8
|
+
<div class="fixed inset-0 w-full h-screen bg-black/75 z-40" @click="$emit('closeModal')"></div>
|
|
3
9
|
|
|
4
10
|
<div
|
|
5
|
-
class="
|
|
11
|
+
class="fixed top-[20vh] left-1/2 -translate-x-1/2 w-96 z-50 rounded-xl shadow-lg bg-white overflow-hidden"
|
|
6
12
|
role="dialog"
|
|
7
13
|
aria-modal="true"
|
|
8
|
-
:aria-labelledby="
|
|
14
|
+
:aria-labelledby="titleId"
|
|
9
15
|
>
|
|
10
16
|
|
|
11
17
|
<div v-if="mode === BaseModalEnum.DELETE" class="p-6 text-center">
|
|
12
18
|
<ExclamationCircleIcon class="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
|
13
19
|
|
|
14
|
-
<h3 :id="
|
|
20
|
+
<h3 :id="titleId" class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
|
15
21
|
Are you sure you want to delete this {{ title }}?
|
|
16
22
|
</h3>
|
|
17
23
|
|
|
@@ -21,6 +27,25 @@
|
|
|
21
27
|
</div>
|
|
22
28
|
</div>
|
|
23
29
|
|
|
30
|
+
<!-- Generic branch (SUCCESS and any other mode): title, optional
|
|
31
|
+
description, caller content, and a single close button. -->
|
|
32
|
+
<div v-else class="p-6 text-center">
|
|
33
|
+
<CheckCircleIcon class="w-12 h-12 text-green-500 mx-auto mb-4" />
|
|
34
|
+
|
|
35
|
+
<h3 :id="titleId" class="mb-2 text-lg font-medium text-gray-900">
|
|
36
|
+
{{ title }}
|
|
37
|
+
</h3>
|
|
38
|
+
<p v-if="description" class="mb-5 text-sm text-gray-500">
|
|
39
|
+
{{ description }}
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<slot></slot>
|
|
43
|
+
|
|
44
|
+
<div class="flex justify-center">
|
|
45
|
+
<base-button :color="BaseButtonEnum.BLUE" @click="$emit('closeModal')" description="OK"/>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
24
49
|
</div>
|
|
25
50
|
</template>
|
|
26
51
|
|
|
@@ -28,10 +53,15 @@
|
|
|
28
53
|
import { onMounted, onUnmounted } from 'vue';
|
|
29
54
|
import { BaseModalEnum } from "../enums/BaseModalEnum";
|
|
30
55
|
import { BaseButtonEnum } from "../enums/BaseButtonEnum";
|
|
31
|
-
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
|
|
56
|
+
import { ExclamationCircleIcon, CheckCircleIcon } from '@heroicons/vue/24/outline'
|
|
57
|
+
import BaseButton from './BaseButton.vue'
|
|
32
58
|
|
|
33
59
|
const emit = defineEmits(['closeModal', 'confirmModal'])
|
|
34
60
|
|
|
61
|
+
// Title text can contain spaces, which would make an interpolated id an
|
|
62
|
+
// invalid aria-labelledby reference — generate a stable per-instance id.
|
|
63
|
+
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`
|
|
64
|
+
|
|
35
65
|
const props = defineProps({
|
|
36
66
|
title: {
|
|
37
67
|
type: String,
|
|
@@ -67,29 +97,3 @@ onUnmounted(() => {
|
|
|
67
97
|
document.removeEventListener('keydown', handleKeydown);
|
|
68
98
|
})
|
|
69
99
|
</script>
|
|
70
|
-
|
|
71
|
-
<style lang="scss" scoped>
|
|
72
|
-
.outside {
|
|
73
|
-
position: fixed;
|
|
74
|
-
top: 0;
|
|
75
|
-
left: 0;
|
|
76
|
-
height: 100vh;
|
|
77
|
-
width: 100%;
|
|
78
|
-
background-color: rgba(0, 0, 0, 0.75);
|
|
79
|
-
z-index: 10;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.dialog {
|
|
83
|
-
position: fixed;
|
|
84
|
-
top: 20vh;
|
|
85
|
-
left: 40%;
|
|
86
|
-
width: 20%;
|
|
87
|
-
z-index: 100;
|
|
88
|
-
border-radius: 12px;
|
|
89
|
-
border: none;
|
|
90
|
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.26);
|
|
91
|
-
padding: 0;
|
|
92
|
-
margin: 0;
|
|
93
|
-
overflow: hidden;
|
|
94
|
-
}
|
|
95
|
-
</style>
|
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
'max-w-4xl'.
|
|
20
20
|
-->
|
|
21
21
|
<div :class="['mx-auto px-4 sm:px-6', maxWidthClass]">
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
<!--
|
|
23
|
+
flex-wrap + gap so a long title and the action buttons reflow onto
|
|
24
|
+
separate rows on narrow (mobile) viewports instead of the actions
|
|
25
|
+
overflowing off the right edge of the screen. min-w-0 on the title
|
|
26
|
+
group lets a long title truncate rather than shoving the actions out.
|
|
27
|
+
-->
|
|
28
|
+
<div class="flex flex-wrap items-center justify-between gap-3 mb-8">
|
|
29
|
+
<div class="flex items-center gap-3 min-w-0">
|
|
24
30
|
<div
|
|
25
31
|
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
|
26
32
|
:class="badgeBgClass"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref,
|
|
2
|
+
import { ref, nextTick, watch } from 'vue'
|
|
3
3
|
import { useRouter, useRoute } from 'vue-router'
|
|
4
4
|
import { Bars3Icon, XMarkIcon } from '@heroicons/vue/24/outline'
|
|
5
5
|
import { useTheme } from '../composables/useTheme'
|
|
@@ -52,14 +52,6 @@ watch(mobileOpen, async (open) => {
|
|
|
52
52
|
}
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
onMounted(() => {
|
|
56
|
-
// The visibility-change health-check hook that lived in WireMate's
|
|
57
|
-
// AppSidebar was application-specific; consumers can wire any
|
|
58
|
-
// periodic work in the `#status` slot themselves.
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
onUnmounted(() => {})
|
|
62
|
-
|
|
63
55
|
function goHome() {
|
|
64
56
|
router.push({ name: props.homeRouteName })
|
|
65
57
|
closeMobile()
|
|
@@ -17,12 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
<div class="ml-3 text-sm font-normal">{{ description }}</div>
|
|
19
19
|
|
|
20
|
-
<button v-if="hasCloseIcon" type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700
|
|
20
|
+
<button v-if="hasCloseIcon" type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700 cursor-pointer" aria-label="Close" @click="close">
|
|
21
21
|
<span class="sr-only">Close</span>
|
|
22
|
-
<XMarkIcon
|
|
23
|
-
class="h-6 w-6 text-gray-500 cursor-pointer"
|
|
24
|
-
@click="close"
|
|
25
|
-
/>
|
|
22
|
+
<XMarkIcon class="h-6 w-6 text-gray-500" />
|
|
26
23
|
</button>
|
|
27
24
|
</div>
|
|
28
25
|
|
|
@@ -37,7 +34,7 @@ import { PositioningEnum } from "../enums/PositioningEnum";
|
|
|
37
34
|
|
|
38
35
|
const props = defineProps({
|
|
39
36
|
mode: {
|
|
40
|
-
type:
|
|
37
|
+
type: String as PropType<BaseToastEnum>,
|
|
41
38
|
required: true
|
|
42
39
|
},
|
|
43
40
|
description: {
|
|
@@ -36,14 +36,15 @@ const pastelColors = [
|
|
|
36
36
|
const bgColor = ref(pastelColors[Math.floor(Math.random() * pastelColors.length)])
|
|
37
37
|
|
|
38
38
|
function computeCss() {
|
|
39
|
+
const base = "font-bold text-xs mr-2 px-2.5 py-0.5 rounded-full";
|
|
39
40
|
let color = 'gray';
|
|
40
41
|
switch (props.color) {
|
|
41
42
|
case ColorsEnums.GREEN: { color = 'green'; break; }
|
|
42
43
|
case ColorsEnums.RED: { color = 'red'; break; }
|
|
43
44
|
case ColorsEnums.BLUE: { color = 'blue'; break; }
|
|
44
45
|
case ColorsEnums.YELLOW: { color = 'yellow'; break; }
|
|
45
|
-
case ColorsEnums.NONE: {
|
|
46
|
+
case ColorsEnums.NONE: { return base; }
|
|
46
47
|
}
|
|
47
|
-
return "
|
|
48
|
+
return base + " bg-" + color + "-100 text-" + color + "-800";
|
|
48
49
|
}
|
|
49
50
|
</script>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import Pagination from './Pagination.vue'
|
|
4
|
+
|
|
5
|
+
/** Visible cell labels in DOM order ('…' for the ellipsis span). */
|
|
6
|
+
function cellLabels(wrapper: ReturnType<typeof mount>): string[] {
|
|
7
|
+
return wrapper.findAll('li').map(li => li.text().trim())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function clickPage(wrapper: ReturnType<typeof mount>, page: number) {
|
|
11
|
+
const link = wrapper.findAll('a').find(a => a.text().trim() === String(page))
|
|
12
|
+
expect(link, `expected a visible cell for page ${page}`).toBeTruthy()
|
|
13
|
+
await link!.trigger('click')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Pagination', () => {
|
|
17
|
+
it('renders every page without an ellipsis when totalPages <= 5', () => {
|
|
18
|
+
const wrapper = mount(Pagination, {
|
|
19
|
+
props: { totalItems: 100, itemsPerPage: 20 },
|
|
20
|
+
})
|
|
21
|
+
expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5'])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders a window with a trailing ellipsis on the first page', () => {
|
|
25
|
+
const wrapper = mount(Pagination, {
|
|
26
|
+
props: { totalItems: 200, itemsPerPage: 20 },
|
|
27
|
+
})
|
|
28
|
+
expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '...', '10'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('keeps cells in ascending order around a middle page', async () => {
|
|
32
|
+
const wrapper = mount(Pagination, {
|
|
33
|
+
props: { totalItems: 200, itemsPerPage: 20 },
|
|
34
|
+
})
|
|
35
|
+
await clickPage(wrapper, 3)
|
|
36
|
+
expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5', '...', '10'])
|
|
37
|
+
|
|
38
|
+
await clickPage(wrapper, 5)
|
|
39
|
+
expect(cellLabels(wrapper)).toEqual(['1', '...', '3', '4', '5', '6', '7', '...', '10'])
|
|
40
|
+
|
|
41
|
+
expect(wrapper.emitted('page-changed')).toEqual([[3], [5]])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('omits the ellipsis when no pages are hidden next to the edges', async () => {
|
|
45
|
+
const wrapper = mount(Pagination, {
|
|
46
|
+
props: { totalItems: 200, itemsPerPage: 20 },
|
|
47
|
+
})
|
|
48
|
+
await clickPage(wrapper, 3)
|
|
49
|
+
await clickPage(wrapper, 4)
|
|
50
|
+
// window is 2..6, so only page 1 precedes it — no gap, no ellipsis
|
|
51
|
+
expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5', '6', '...', '10'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders the trailing window on the last page', async () => {
|
|
55
|
+
const wrapper = mount(Pagination, {
|
|
56
|
+
props: { totalItems: 200, itemsPerPage: 20 },
|
|
57
|
+
})
|
|
58
|
+
await clickPage(wrapper, 3)
|
|
59
|
+
await clickPage(wrapper, 5)
|
|
60
|
+
await clickPage(wrapper, 7)
|
|
61
|
+
await clickPage(wrapper, 9)
|
|
62
|
+
await clickPage(wrapper, 10)
|
|
63
|
+
expect(cellLabels(wrapper)).toEqual(['1', '...', '8', '9', '10'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not emit when the active page is clicked again', async () => {
|
|
67
|
+
const wrapper = mount(Pagination, {
|
|
68
|
+
props: { totalItems: 200, itemsPerPage: 20 },
|
|
69
|
+
})
|
|
70
|
+
await clickPage(wrapper, 1)
|
|
71
|
+
expect(wrapper.emitted('page-changed')).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('clamps the current page when the item count shrinks', async () => {
|
|
75
|
+
const wrapper = mount(Pagination, {
|
|
76
|
+
props: { totalItems: 200, itemsPerPage: 20 },
|
|
77
|
+
})
|
|
78
|
+
await clickPage(wrapper, 3)
|
|
79
|
+
await clickPage(wrapper, 5)
|
|
80
|
+
await clickPage(wrapper, 7)
|
|
81
|
+
await wrapper.setProps({ totalItems: 40 })
|
|
82
|
+
expect(cellLabels(wrapper)).toEqual(['1', '2'])
|
|
83
|
+
expect(wrapper.find('a.active').text()).toBe('2')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div>
|
|
3
|
-
<ul>
|
|
4
|
-
<li v-for="(cell, index) in cells" :key="index">
|
|
5
|
-
<a
|
|
6
|
-
|
|
3
|
+
<ul class="flex list-none p-0 m-0">
|
|
4
|
+
<li v-for="(cell, index) in cells" :key="index" class="mr-1">
|
|
5
|
+
<a
|
|
6
|
+
v-if="cell !== 0"
|
|
7
|
+
role="button"
|
|
8
|
+
tabindex="0"
|
|
9
|
+
@click="changePage(cell)"
|
|
10
|
+
@keydown.enter="changePage(cell)"
|
|
11
|
+
class="inline-block border border-gray-300 rounded-lg p-3 no-underline text-black cursor-pointer hover:bg-gray-300"
|
|
12
|
+
:class="{ 'active font-bold bg-gray-200': cell === currentPage }"
|
|
13
|
+
>{{ cell }}</a>
|
|
14
|
+
<span v-else class="inline-block border border-gray-300 rounded-lg p-3 text-black cursor-not-allowed">...</span>
|
|
7
15
|
</li>
|
|
8
16
|
</ul>
|
|
9
17
|
</div>
|
|
@@ -28,110 +36,47 @@ const totalPages = ref(0);
|
|
|
28
36
|
const cells = ref([] as number[]);
|
|
29
37
|
const emit = defineEmits(['page-changed']);
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
// Rebuild the visible cells around `page`. A cell value of 0 renders as
|
|
40
|
+
// an ellipsis. The window is the current page ± 2 neighbours, plus the
|
|
41
|
+
// first/last page with an ellipsis only when pages are actually hidden.
|
|
42
|
+
function buildCells(page: number) {
|
|
43
|
+
if (totalPages.value <= 5) {
|
|
44
|
+
cells.value = Array.from({ length: totalPages.value }, (_, i) => i + 1);
|
|
45
|
+
return;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
let isAwayFromStartPoint = (pageNumber - 3) > 0;
|
|
40
|
-
if (isAwayFromStartPoint) {
|
|
41
|
-
cells.value.push(1);
|
|
42
|
-
cells.value.push(0);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (let i = 1; i <= 2; i++) {
|
|
46
|
-
let previousCell = pageNumber - i;
|
|
47
|
-
if (previousCell > 0) {
|
|
48
|
-
cells.value.push(previousCell);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Sort previous pages in ascending order
|
|
53
|
-
const currentIdx = cells.value.length;
|
|
54
|
-
cells.value.push(currentPage.value);
|
|
48
|
+
const out: number[] = [];
|
|
49
|
+
const start = Math.max(1, page - 2);
|
|
50
|
+
const end = Math.min(totalPages.value, page + 2);
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} else {
|
|
67
|
-
for (let i = 1; i <= nextPages; i++) {
|
|
68
|
-
cells.value.push(pageNumber + i);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
52
|
+
if (start > 1) {
|
|
53
|
+
out.push(1);
|
|
54
|
+
if (start > 2) out.push(0);
|
|
55
|
+
}
|
|
56
|
+
for (let i = start; i <= end; i++) {
|
|
57
|
+
out.push(i);
|
|
58
|
+
}
|
|
59
|
+
if (end < totalPages.value) {
|
|
60
|
+
if (end < totalPages.value - 1) out.push(0);
|
|
61
|
+
out.push(totalPages.value);
|
|
71
62
|
}
|
|
63
|
+
cells.value = out;
|
|
64
|
+
}
|
|
72
65
|
|
|
66
|
+
function changePage(pageNumber: number) {
|
|
67
|
+
if (pageNumber === currentPage.value || pageNumber < 1 || pageNumber > totalPages.value) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
currentPage.value = pageNumber;
|
|
71
|
+
buildCells(pageNumber);
|
|
73
72
|
emit('page-changed', pageNumber);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
watch(() => props.totalItems, () => {
|
|
75
|
+
watch(() => [props.totalItems, props.itemsPerPage], () => {
|
|
77
76
|
totalPages.value = Math.ceil(props.totalItems / props.itemsPerPage);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (totalPages.value > 5) {
|
|
81
|
-
for (let i = 1; i < 5; i++) {
|
|
82
|
-
cells.value.push(i);
|
|
83
|
-
}
|
|
84
|
-
cells.value.push(0);
|
|
85
|
-
cells.value.push(totalPages.value);
|
|
86
|
-
} else {
|
|
87
|
-
for (let i = 1; i <= totalPages.value; i++) {
|
|
88
|
-
cells.value.push(i);
|
|
89
|
-
}
|
|
77
|
+
if (currentPage.value > totalPages.value) {
|
|
78
|
+
currentPage.value = Math.max(1, totalPages.value);
|
|
90
79
|
}
|
|
80
|
+
buildCells(currentPage.value);
|
|
91
81
|
}, { immediate: true })
|
|
92
82
|
</script>
|
|
93
|
-
|
|
94
|
-
<style lang="scss" scoped>
|
|
95
|
-
span {
|
|
96
|
-
border: 1px solid #d1d5db;
|
|
97
|
-
border-radius: 10px;
|
|
98
|
-
padding: 0.75rem;
|
|
99
|
-
text-decoration: none;
|
|
100
|
-
color: black;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
ul {
|
|
104
|
-
display: flex;
|
|
105
|
-
list-style: none;
|
|
106
|
-
padding: 0;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
li {
|
|
110
|
-
margin-right: 3px;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
a {
|
|
114
|
-
border: 1px solid #d1d5db;
|
|
115
|
-
border-radius: 10px;
|
|
116
|
-
padding: 0.75rem;
|
|
117
|
-
text-decoration: none;
|
|
118
|
-
color: black;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
a:hover {
|
|
122
|
-
background-color: rgb(195 197 201);
|
|
123
|
-
color: #000000;
|
|
124
|
-
cursor: pointer;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
a.active:hover {
|
|
128
|
-
background-color: rgb(195 197 201);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
a.active {
|
|
132
|
-
font-weight: 700;
|
|
133
|
-
border-radius: 10px;
|
|
134
|
-
background-color: #e5e7eb;
|
|
135
|
-
color: #000000;
|
|
136
|
-
}
|
|
137
|
-
</style>
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<ArrowDownIcon class="h-5 w-5 text-red-500" />
|
|
6
6
|
</span>
|
|
7
7
|
|
|
8
|
+
<!-- Flat trend: neutral badge, no directional arrow. -->
|
|
9
|
+
<span v-else-if="isZero" class="flex items-center space-x-2">
|
|
10
|
+
<BaseBadge :color="ColorsEnums.GRAY">{{ number }}{{ props.icon }}</BaseBadge>
|
|
11
|
+
<MinusIcon class="h-5 w-5 text-gray-400" />
|
|
12
|
+
</span>
|
|
13
|
+
|
|
8
14
|
<span v-else class="flex items-center space-x-2">
|
|
9
15
|
<BaseBadge :color="ColorsEnums.GREEN">{{ number }}{{ props.icon }}</BaseBadge>
|
|
10
16
|
<ArrowUpIcon class="h-5 w-5 text-green-500" />
|
|
@@ -16,7 +22,7 @@
|
|
|
16
22
|
import { computed } from 'vue'
|
|
17
23
|
import BaseBadge from './BaseBadge.vue'
|
|
18
24
|
import { ColorsEnums } from '../enums/ColorsEnums'
|
|
19
|
-
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/vue/24/outline'
|
|
25
|
+
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '@heroicons/vue/24/outline'
|
|
20
26
|
|
|
21
27
|
const props = defineProps({
|
|
22
28
|
number: {
|
|
@@ -30,4 +36,5 @@ const props = defineProps({
|
|
|
30
36
|
})
|
|
31
37
|
|
|
32
38
|
const isNegative = computed(() => props.number !== null && props.number !== undefined && props.number < 0)
|
|
39
|
+
const isZero = computed(() => props.number === 0)
|
|
33
40
|
</script>
|
|
@@ -24,7 +24,19 @@ let initialized = false
|
|
|
24
24
|
let configuredStorageKey = 'mgv-theme'
|
|
25
25
|
|
|
26
26
|
function initialize(storageKey: string) {
|
|
27
|
-
if (initialized)
|
|
27
|
+
if (initialized) {
|
|
28
|
+
// A different key after initialization means the app configured the
|
|
29
|
+
// theme too late (a library component mounted first and locked in the
|
|
30
|
+
// default). Surface it instead of silently ignoring the option.
|
|
31
|
+
if (storageKey !== configuredStorageKey) {
|
|
32
|
+
console.warn(
|
|
33
|
+
`[ui-lib] useTheme: storage key "${storageKey}" ignored — theme already `
|
|
34
|
+
+ `initialized with "${configuredStorageKey}". Call initTheme({ storageKey }) `
|
|
35
|
+
+ `before mounting any component.`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return
|
|
39
|
+
}
|
|
28
40
|
initialized = true
|
|
29
41
|
configuredStorageKey = storageKey
|
|
30
42
|
|
|
@@ -55,6 +67,16 @@ function initialize(storageKey: string) {
|
|
|
55
67
|
}
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Explicitly initialize the theme singleton. Call this in the app's entry
|
|
72
|
+
* point (before mounting) when you need a custom storage key — library
|
|
73
|
+
* components call useTheme() internally, so waiting until a view calls
|
|
74
|
+
* useTheme({ storageKey }) risks a component locking in the default first.
|
|
75
|
+
*/
|
|
76
|
+
export function initTheme(options?: UseThemeOptions): void {
|
|
77
|
+
initialize(options?.storageKey ?? configuredStorageKey)
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
export function useTheme(options?: UseThemeOptions) {
|
|
59
81
|
initialize(options?.storageKey ?? configuredStorageKey)
|
|
60
82
|
|
package/src/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export { default as BaseActionButton } from './components/BaseActionButton.vue'
|
|
|
32
32
|
export { default as BaseCopyButton } from './components/BaseCopyButton.vue'
|
|
33
33
|
|
|
34
34
|
// Composables
|
|
35
|
-
export { useTheme } from './composables/useTheme'
|
|
35
|
+
export { useTheme, initTheme } from './composables/useTheme'
|
|
36
36
|
export type { UseThemeOptions } from './composables/useTheme'
|
|
37
37
|
export { useThemeClasses } from './composables/useThemeClasses'
|
|
38
38
|
export type { ThemeClasses } from './composables/useThemeClasses'
|