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.
@@ -1,17 +1,23 @@
1
1
  <template>
2
- <div class="outside" @click="$emit('closeModal')"></div>
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="dialog relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"
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="'modal-title-' + title"
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="'modal-title-' + title" class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
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
- <div class="flex items-center justify-between mb-8">
23
- <div class="flex items-center gap-3">
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"
@@ -9,7 +9,7 @@ const props = defineProps({
9
9
  bgColor: {
10
10
  type: String,
11
11
  required: false,
12
- default: "white"
12
+ default: "bg-white"
13
13
  }
14
14
  })
15
15
 
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
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" data-dismiss-target="#toast-danger" aria-label="Close">
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: Object as PropType<BaseToastEnum>,
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: { color = ''; break; }
46
+ case ColorsEnums.NONE: { return base; }
46
47
  }
47
- return "font-bold bg-" + color + "-100 text-" + color + "-800 text-xs mr-2 px-2.5 py-0.5 rounded-full";
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 v-if="cell!==0" role="button" tabindex="0" @click="changePage(cell)" @keydown.enter="changePage(cell)" :class="{ active: cell === currentPage }">{{ cell }}</a>
6
- <span v-if="cell===0" style="cursor: not-allowed;">...</span>
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
- function changePage(pageNumber: number) {
32
- if (pageNumber !== currentPage.value && pageNumber >= 1 && pageNumber <= totalPages.value) {
33
- currentPage.value = pageNumber;
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
- if (totalPages.value > 5) {
37
- cells.value = [];
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
- let nextPages = totalPages.value - pageNumber;
57
- let hasNextPages = nextPages > 2;
58
- let nextPagesSize = 2;
59
-
60
- if (hasNextPages) {
61
- for (let i = 1; i <= nextPagesSize; i++) {
62
- cells.value.push(pageNumber + i);
63
- }
64
- cells.value.push(0);
65
- cells.value.push(totalPages.value);
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
- cells.value = [];
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) return
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'