mgv-backoffice 1.24.0 → 1.27.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/dist/components/BaseStatBreakdown.vue.d.ts +14 -0
- package/dist/index.d.ts +3 -1
- package/dist/types/statBreakdown.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +276 -238
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +7 -0
- package/package.json +54 -54
- package/src/components/BaseActionButton.vue +1 -1
- package/src/components/BaseBreadcrumb.vue +2 -2
- package/src/components/BaseButton.vue +4 -3
- package/src/components/BaseCollapsibleSection.vue +10 -4
- package/src/components/BaseConfirmModal.vue +3 -2
- package/src/components/BaseCopyButton.vue +2 -2
- package/src/components/BaseDropdown.vue +6 -4
- package/src/components/BaseEntityPickerModal.vue +7 -5
- package/src/components/BaseInput.vue +1 -1
- package/src/components/BaseNotFoundPage.vue +1 -1
- package/src/components/BaseSegmentedControl.vue +2 -2
- package/src/components/BaseSelect.vue +1 -1
- package/src/components/BaseSidebar.vue +3 -3
- package/src/components/BaseStatBreakdown.test.ts +56 -0
- package/src/components/BaseStatBreakdown.vue +49 -0
- package/src/components/BaseTextInputModal.vue +2 -2
- package/src/components/BaseToolbarButton.vue +1 -1
- package/src/components/EuroAmount.vue +2 -2
- package/src/components/Pagination.test.ts +2 -2
- package/src/components/Pagination.vue +10 -10
- package/src/index.ts +3 -0
- package/src/style.css +9 -0
- package/src/types/statBreakdown.ts +6 -0
- package/src/utils/format.test.ts +35 -0
- package/src/utils/format.ts +19 -0
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mgv-backoffice",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Shared Vue 3 UI component library",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/ui-lib.umd.cjs",
|
|
7
|
-
"module": "./dist/ui-lib.js",
|
|
8
|
-
"types": "./dist/index.d.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"types": "./dist/index.d.ts",
|
|
12
|
-
"import": "./dist/ui-lib.js",
|
|
13
|
-
"require": "./dist/ui-lib.umd.cjs"
|
|
14
|
-
},
|
|
15
|
-
"./dist/style.css": "./dist/ui-lib.css",
|
|
16
|
-
"./tailwind.safelist": "./tailwind.safelist.js",
|
|
17
|
-
"./tailwind.safelist.css": "./tailwind.safelist.css"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist",
|
|
21
|
-
"src",
|
|
22
|
-
"tailwind.safelist.js",
|
|
23
|
-
"tailwind.safelist.css"
|
|
24
|
-
],
|
|
25
|
-
"scripts": {
|
|
26
|
-
"safelist": "node scripts/generate-safelist.mjs",
|
|
27
|
-
"prebuild": "npm run safelist",
|
|
28
|
-
"build": "vite build",
|
|
29
|
-
"dev": "vite build --watch",
|
|
30
|
-
"test": "vitest run",
|
|
31
|
-
"test:watch": "vitest"
|
|
32
|
-
},
|
|
33
|
-
"peerDependencies": {
|
|
34
|
-
"@heroicons/vue": "^2.0.0",
|
|
35
|
-
"vue": "^3.5.0",
|
|
36
|
-
"vue-router": "^5.0.0"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@heroicons/vue": "^2.2.0",
|
|
40
|
-
"@tailwindcss/vite": "^4.3.1",
|
|
41
|
-
"@vitejs/plugin-vue": "^6.0.7",
|
|
42
|
-
"@vue/language-core": "^3.3.5",
|
|
43
|
-
"@vue/test-utils": "^2.4.11",
|
|
44
|
-
"jsdom": "^29.1.1",
|
|
45
|
-
"sass": "^1.101.0",
|
|
46
|
-
"tailwindcss": "^4.3.1",
|
|
47
|
-
"typescript": "^6.0.3",
|
|
48
|
-
"vite": "^8.1.0",
|
|
49
|
-
"vite-plugin-dts": "^5.0.3",
|
|
50
|
-
"vitest": "^4.1.9",
|
|
51
|
-
"vue": "^3.5.38",
|
|
52
|
-
"vue-router": "^5.1.0"
|
|
53
|
-
}
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "mgv-backoffice",
|
|
3
|
+
"version": "1.27.0",
|
|
4
|
+
"description": "Shared Vue 3 UI component library",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/ui-lib.umd.cjs",
|
|
7
|
+
"module": "./dist/ui-lib.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/ui-lib.js",
|
|
13
|
+
"require": "./dist/ui-lib.umd.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./dist/style.css": "./dist/ui-lib.css",
|
|
16
|
+
"./tailwind.safelist": "./tailwind.safelist.js",
|
|
17
|
+
"./tailwind.safelist.css": "./tailwind.safelist.css"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"tailwind.safelist.js",
|
|
23
|
+
"tailwind.safelist.css"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"safelist": "node scripts/generate-safelist.mjs",
|
|
27
|
+
"prebuild": "npm run safelist",
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"dev": "vite build --watch",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@heroicons/vue": "^2.0.0",
|
|
35
|
+
"vue": "^3.5.0",
|
|
36
|
+
"vue-router": "^5.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@heroicons/vue": "^2.2.0",
|
|
40
|
+
"@tailwindcss/vite": "^4.3.1",
|
|
41
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
42
|
+
"@vue/language-core": "^3.3.5",
|
|
43
|
+
"@vue/test-utils": "^2.4.11",
|
|
44
|
+
"jsdom": "^29.1.1",
|
|
45
|
+
"sass": "^1.101.0",
|
|
46
|
+
"tailwindcss": "^4.3.1",
|
|
47
|
+
"typescript": "^6.0.3",
|
|
48
|
+
"vite": "^8.1.0",
|
|
49
|
+
"vite-plugin-dts": "^5.0.3",
|
|
50
|
+
"vitest": "^4.1.9",
|
|
51
|
+
"vue": "^3.5.38",
|
|
52
|
+
"vue-router": "^5.1.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
:disabled="disabled"
|
|
26
26
|
:title="title"
|
|
27
27
|
:aria-label="ariaLabel"
|
|
28
|
-
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
28
|
+
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-current"
|
|
29
29
|
:class="[fullWidth ? 'flex-1' : '', stateClass]"
|
|
30
30
|
@click="$emit('click', $event)"
|
|
31
31
|
>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
|
4
4
|
|
|
5
5
|
<li class="inline-flex items-center" style="margin-left: 0;">
|
|
6
|
-
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white">
|
|
6
|
+
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
7
7
|
<HomeIcon class="h-4 w-4 text-gray-500 mr-2" />
|
|
8
8
|
Home
|
|
9
9
|
</router-link>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<li v-for="(crumb, index) in crumbs" :key="index" class="inline-flex items-center" style="margin-left: 0;">
|
|
13
13
|
<ChevronRightIcon class="h-4 w-4 text-gray-500" />
|
|
14
|
-
<router-link :to="crumb.url" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white">
|
|
14
|
+
<router-link :to="crumb.url" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
15
15
|
{{ crumb.name }}
|
|
16
16
|
</router-link>
|
|
17
17
|
</li>
|
|
@@ -139,7 +139,7 @@ function outlineCss() {
|
|
|
139
139
|
const isDisabled = props.isDisable || props.isLoading;
|
|
140
140
|
return isDisabled
|
|
141
141
|
? `border bg-transparent ${tone} opacity-50 cursor-not-allowed`
|
|
142
|
-
: `focus:outline-none border bg-transparent ${tone}`;
|
|
142
|
+
: `focus:outline-none focus-visible:ring-2 focus-visible:ring-current border bg-transparent ${tone}`;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// Per-colour ghost palette: borderless, transparent fill, coloured text,
|
|
@@ -198,12 +198,13 @@ function ghostCss() {
|
|
|
198
198
|
const isDisabled = props.isDisable || props.isLoading;
|
|
199
199
|
return isDisabled
|
|
200
200
|
? `bg-transparent ${tone} opacity-60 cursor-not-allowed`
|
|
201
|
-
: `focus:outline-none bg-transparent ${tone} cursor-pointer`;
|
|
201
|
+
: `focus:outline-none focus-visible:ring-2 focus-visible:ring-current bg-transparent ${tone} cursor-pointer`;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
function computeCss() {
|
|
205
205
|
const isDisabled = props.isDisable || props.isLoading;
|
|
206
|
-
let commonCss = "inline-flex items-center";
|
|
206
|
+
let commonCss = "inline-flex items-center transition-colors duration-150";
|
|
207
|
+
if (!isDisabled) { commonCss += " cursor-pointer"; }
|
|
207
208
|
let css;
|
|
208
209
|
switch (props.color) {
|
|
209
210
|
case BaseButtonEnum.BLUE: {
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
• toggle — fired when the user clicks the header. The parent
|
|
19
19
|
flips its own collapsed state in response.
|
|
20
20
|
-->
|
|
21
|
-
|
|
21
|
+
<!--
|
|
22
|
+
NOTE: no `overflow-hidden` on the section — it would clip floating
|
|
23
|
+
menus (BaseDropdown) rendered near the bottom of the body. The child
|
|
24
|
+
corners are rounded explicitly instead so the card silhouette is
|
|
25
|
+
unchanged.
|
|
26
|
+
-->
|
|
27
|
+
<section class="rounded-xl border transition-colors" :class="t.cardAlt">
|
|
22
28
|
<button
|
|
23
29
|
type="button"
|
|
24
|
-
class="w-full flex items-center justify-between p-4 text-left transition-colors"
|
|
25
|
-
:class="headerClasses"
|
|
30
|
+
class="w-full flex items-center justify-between p-4 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
|
|
31
|
+
:class="[headerClasses, collapsed ? 'rounded-xl' : 'rounded-t-xl']"
|
|
26
32
|
:aria-expanded="!collapsed"
|
|
27
33
|
@click="emit('toggle')"
|
|
28
34
|
>
|
|
@@ -50,7 +56,7 @@
|
|
|
50
56
|
</button>
|
|
51
57
|
<div
|
|
52
58
|
v-show="!collapsed"
|
|
53
|
-
class="p-6 border-t"
|
|
59
|
+
class="p-6 border-t rounded-b-xl"
|
|
54
60
|
:class="[t.border, resolvedBodyClass]"
|
|
55
61
|
>
|
|
56
62
|
<slot />
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
<button
|
|
45
45
|
type="button"
|
|
46
46
|
:disabled="submitting"
|
|
47
|
-
class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150"
|
|
47
|
+
class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
48
48
|
:class="[
|
|
49
49
|
t.ghostButton,
|
|
50
50
|
submitting ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
<button
|
|
58
58
|
type="button"
|
|
59
59
|
:disabled="submitting"
|
|
60
|
-
class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150"
|
|
60
|
+
class="inline-flex items-center justify-center gap-2 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-offset-2"
|
|
61
61
|
:class="[
|
|
62
|
+
variant === 'danger' ? 'focus-visible:ring-red-500' : 'focus-visible:ring-emerald-500',
|
|
62
63
|
variant === 'danger'
|
|
63
64
|
? submitting ? 'bg-red-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700 cursor-pointer'
|
|
64
65
|
: submitting ? 'bg-emerald-400 cursor-not-allowed' : 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
|
|
@@ -75,7 +75,7 @@ const ariaLabel = computed(() => (props.label ? `Copy ${props.label}` : 'Copy'))
|
|
|
75
75
|
const buttonClass = computed(() => {
|
|
76
76
|
if (props.variant === 'bordered') {
|
|
77
77
|
const base =
|
|
78
|
-
'inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer shrink-0'
|
|
78
|
+
'inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500'
|
|
79
79
|
const state = copied.value
|
|
80
80
|
? isDark.value
|
|
81
81
|
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400'
|
|
@@ -86,7 +86,7 @@ const buttonClass = computed(() => {
|
|
|
86
86
|
return `${base} ${state}`
|
|
87
87
|
}
|
|
88
88
|
// ghost
|
|
89
|
-
const base = 'p-1 rounded transition-colors cursor-pointer'
|
|
89
|
+
const base = 'p-1 rounded transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500'
|
|
90
90
|
const state = isDark.value
|
|
91
91
|
? 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-500/10'
|
|
92
92
|
: 'text-gray-500 hover:text-emerald-600 hover:bg-emerald-50'
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
:aria-expanded="open"
|
|
26
26
|
@click="toggle"
|
|
27
27
|
>
|
|
28
|
-
|
|
28
|
+
<!-- min-w-0 + truncate so long labels ellipsize inside narrow
|
|
29
|
+
(fixed-column) triggers instead of wrapping or widening them. -->
|
|
30
|
+
<span class="min-w-0 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
|
|
29
31
|
{{ selectedOption ? selectedOption.label : placeholder }}
|
|
30
32
|
</span>
|
|
31
33
|
<ChevronDownIcon
|
|
@@ -39,7 +41,7 @@
|
|
|
39
41
|
v-if="open"
|
|
40
42
|
role="listbox"
|
|
41
43
|
:aria-label="ariaLabel || placeholder"
|
|
42
|
-
class="absolute z-20 mt-1 w-full max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
|
|
44
|
+
class="absolute z-20 mt-1 w-full min-w-max max-w-xs max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
|
|
43
45
|
>
|
|
44
46
|
<li
|
|
45
47
|
v-for="opt in options"
|
|
@@ -112,7 +114,7 @@ const triggerClass = computed(() => {
|
|
|
112
114
|
'flex items-center justify-between gap-2 rounded-lg border text-left text-sm transition-colors ' +
|
|
113
115
|
'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
|
|
114
116
|
'hover:bg-slate-50 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
|
|
115
|
-
'disabled:opacity-50 disabled:cursor-not-allowed ' +
|
|
117
|
+
'cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ' +
|
|
116
118
|
padding
|
|
117
119
|
)
|
|
118
120
|
})
|
|
@@ -120,7 +122,7 @@ const triggerClass = computed(() => {
|
|
|
120
122
|
function rowClass(opt: DropdownOption): string {
|
|
121
123
|
const selected = opt.value === props.modelValue
|
|
122
124
|
const base =
|
|
123
|
-
'block w-full px-4 py-2 text-left text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
|
125
|
+
'block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
124
126
|
const fill = selected
|
|
125
127
|
? 'bg-emerald-500 text-white'
|
|
126
128
|
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
|
|
@@ -55,10 +55,12 @@
|
|
|
55
55
|
|
|
56
56
|
<!-- Item list -->
|
|
57
57
|
<div v-if="!loading && !loadError" class="mt-3 max-h-64 overflow-y-auto">
|
|
58
|
-
<
|
|
58
|
+
<button
|
|
59
59
|
v-for="item in filteredItems"
|
|
60
60
|
:key="item.id"
|
|
61
|
-
|
|
61
|
+
type="button"
|
|
62
|
+
:aria-pressed="selectedId === item.id"
|
|
63
|
+
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
64
|
:class="[
|
|
63
65
|
selectedId === item.id
|
|
64
66
|
? palette.selectedRow
|
|
@@ -81,7 +83,7 @@
|
|
|
81
83
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
82
84
|
</svg>
|
|
83
85
|
</div>
|
|
84
|
-
</
|
|
86
|
+
</button>
|
|
85
87
|
|
|
86
88
|
<!-- Empty states -->
|
|
87
89
|
<div v-if="items.length === 0" class="text-center py-6">
|
|
@@ -100,7 +102,7 @@
|
|
|
100
102
|
<button
|
|
101
103
|
type="button"
|
|
102
104
|
: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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
105
|
+
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
106
|
:class="t.ghostButton"
|
|
105
107
|
@click="handleCancel"
|
|
106
108
|
>
|
|
@@ -109,7 +111,7 @@
|
|
|
109
111
|
<button
|
|
110
112
|
type="button"
|
|
111
113
|
: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"
|
|
114
|
+
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
115
|
:class="!selectedId || submitting ? palette.confirmDisabled : palette.confirmActive"
|
|
114
116
|
@click="handleConfirm"
|
|
115
117
|
>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<input
|
|
12
12
|
:value="modelValue"
|
|
13
13
|
:type="type"
|
|
14
|
-
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"
|
|
14
|
+
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 placeholder:text-slate-400 dark:placeholder:text-slate-500 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"
|
|
15
15
|
:class="[block ? 'w-full' : '', size === 'sm' ? 'px-2 py-1.5' : 'px-3 py-2']"
|
|
16
16
|
@input="onInput"
|
|
17
17
|
/>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<p class="text-lg mb-6" :class="t.dimTextAlt">{{ message }}</p>
|
|
9
9
|
<button
|
|
10
10
|
@click="goHome"
|
|
11
|
-
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors cursor-pointer"
|
|
11
|
+
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
|
12
12
|
:class="isDark ? 'bg-emerald-600 hover:bg-emerald-500' : 'bg-emerald-600 hover:bg-emerald-700'"
|
|
13
13
|
>
|
|
14
14
|
{{ homeLabel }}
|
|
@@ -75,12 +75,12 @@ const wrapClass = computed(() =>
|
|
|
75
75
|
const buttonClass = computed(() => {
|
|
76
76
|
if (props.variant === 'toolbar') {
|
|
77
77
|
return (
|
|
78
|
-
'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors ' +
|
|
78
|
+
'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors cursor-pointer ' +
|
|
79
79
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500'
|
|
80
80
|
)
|
|
81
81
|
}
|
|
82
82
|
const padding = props.variant === 'wide' ? 'px-4 py-2' : 'px-3 py-2'
|
|
83
|
-
return `${padding} text-sm font-medium transition-colors border-l first:border-l-0 border-slate-300 dark:border-slate-600`
|
|
83
|
+
return `${padding} text-sm font-medium transition-colors cursor-pointer border-l first:border-l-0 border-slate-300 dark:border-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500`
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
const activeFill = computed(() =>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
-->
|
|
8
8
|
<select
|
|
9
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"
|
|
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
11
|
:class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
|
|
12
12
|
@change="onChange"
|
|
13
13
|
>
|
|
@@ -94,7 +94,7 @@ function navigate(name: string) {
|
|
|
94
94
|
<!-- Mobile top bar (hidden on desktop via scoped CSS to avoid flex/hidden conflict) -->
|
|
95
95
|
<div class="mobile-topbar fixed top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3 border-b transition-colors"
|
|
96
96
|
:class="isDark ? 'bg-gray-900 border-gray-800' : 'bg-white border-gray-200'">
|
|
97
|
-
<button type="button" @click="goHome" class="cursor-pointer" aria-label="Go to home">
|
|
97
|
+
<button type="button" @click="goHome" class="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500" aria-label="Go to home">
|
|
98
98
|
<!--
|
|
99
99
|
`logo` slot: consumers drop their brand logo in here. Receives
|
|
100
100
|
the current size hint so a logo component can scale to match.
|
|
@@ -104,7 +104,7 @@ function navigate(name: string) {
|
|
|
104
104
|
<button
|
|
105
105
|
type="button"
|
|
106
106
|
@click="toggleMobile"
|
|
107
|
-
class="p-2 rounded-lg cursor-pointer"
|
|
107
|
+
class="p-2 rounded-lg cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
108
108
|
:class="isDark ? 'text-gray-400 hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100'"
|
|
109
109
|
:aria-label="mobileOpen ? 'Close navigation menu' : 'Open navigation menu'"
|
|
110
110
|
:aria-expanded="mobileOpen"
|
|
@@ -138,7 +138,7 @@ function navigate(name: string) {
|
|
|
138
138
|
class="px-5 pt-5 pb-4 flex items-center justify-center relative"
|
|
139
139
|
:class="{ 'lg:px-2': isRail }"
|
|
140
140
|
>
|
|
141
|
-
<button type="button" @click="goHome" class="cursor-pointer" aria-label="Go to home">
|
|
141
|
+
<button type="button" @click="goHome" class="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500" aria-label="Go to home">
|
|
142
142
|
<slot name="logo" :size="isRail ? 36 : 52" />
|
|
143
143
|
</button>
|
|
144
144
|
<button
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseStatBreakdown from './BaseStatBreakdown.vue'
|
|
4
|
+
import type { StatBreakdownItem } from '../types/statBreakdown'
|
|
5
|
+
|
|
6
|
+
const items: StatBreakdownItem[] = [
|
|
7
|
+
{ label: 'Alpaca', value: 12.5 },
|
|
8
|
+
{ label: 'Kraken', value: -3 },
|
|
9
|
+
{ label: 'Binance', value: null },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseStatBreakdown', () => {
|
|
13
|
+
it('renders one row per item with label and formatted value', () => {
|
|
14
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
15
|
+
props: { items, currency: '$' },
|
|
16
|
+
})
|
|
17
|
+
const rows = wrapper.findAll('.flex')
|
|
18
|
+
expect(rows).toHaveLength(3)
|
|
19
|
+
expect(rows[0].text()).toContain('Alpaca')
|
|
20
|
+
expect(rows[0].text()).toContain('$12.50')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders an em-dash for null values instead of a misleading 0', () => {
|
|
24
|
+
const wrapper = mount(BaseStatBreakdown, { props: { items } })
|
|
25
|
+
const rows = wrapper.findAll('.flex')
|
|
26
|
+
expect(rows[2].text()).toContain('—')
|
|
27
|
+
expect(rows[2].text()).not.toContain('0.00')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('honours the decimals prop', () => {
|
|
31
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
32
|
+
props: { items: [{ label: 'BTC', value: 0.5 }], decimals: 8 },
|
|
33
|
+
})
|
|
34
|
+
expect(wrapper.text()).toContain('0.50000000')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('prefixes a plus and colours by sign when signed', () => {
|
|
38
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
39
|
+
props: { items, currency: '$', signed: true },
|
|
40
|
+
})
|
|
41
|
+
const values = wrapper.findAll('.font-mono')
|
|
42
|
+
expect(values[0].text()).toBe('+$12.50')
|
|
43
|
+
expect(values[0].classes().join(' ')).toContain('text-emerald-600')
|
|
44
|
+
expect(values[1].text()).toBe('-$3.00')
|
|
45
|
+
expect(values[1].classes().join(' ')).toContain('text-red-600')
|
|
46
|
+
// Null rows stay neutral even in signed mode.
|
|
47
|
+
expect(values[2].classes().join(' ')).toContain('text-slate-500')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('stays neutral-coloured when signed is off', () => {
|
|
51
|
+
const wrapper = mount(BaseStatBreakdown, {
|
|
52
|
+
props: { items: [{ label: 'Alpaca', value: 12.5 }] },
|
|
53
|
+
})
|
|
54
|
+
expect(wrapper.find('.font-mono').classes().join(' ')).toContain('text-slate-500')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Compact per-item breakdown meant to sit under a summary/stat card (pairs
|
|
3
|
+
// with EarningsCard). Each item renders on its own line — label left, value
|
|
4
|
+
// right in monospace. A null/undefined value (a source that is unconfigured,
|
|
5
|
+
// unreachable, or has no matching rows) shows a dash rather than a
|
|
6
|
+
// misleading 0. With `signed`, values gain an explicit "+" and are coloured
|
|
7
|
+
// green/red by sign.
|
|
8
|
+
import type { StatBreakdownItem } from '../types/statBreakdown'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
items: StatBreakdownItem[]
|
|
13
|
+
currency?: string
|
|
14
|
+
decimals?: number
|
|
15
|
+
signed?: boolean
|
|
16
|
+
}>(),
|
|
17
|
+
{ currency: '', decimals: 2, signed: false },
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
function fmt(v: number | null | undefined): string {
|
|
21
|
+
if (v === null || v === undefined) return '—'
|
|
22
|
+
// Sign sits before the currency symbol ("-$3.00", "+$5.00" — same shape
|
|
23
|
+
// as fmtUsd); the plus only appears in signed mode.
|
|
24
|
+
const sign = v < 0 ? '-' : props.signed ? '+' : ''
|
|
25
|
+
return `${sign}${props.currency}${Math.abs(v).toFixed(props.decimals)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function color(v: number | null | undefined): string {
|
|
29
|
+
if (!props.signed || v === null || v === undefined) {
|
|
30
|
+
return 'text-slate-500 dark:text-slate-400'
|
|
31
|
+
}
|
|
32
|
+
return v >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="mt-1.5 space-y-0.5 px-1">
|
|
38
|
+
<div
|
|
39
|
+
v-for="item in items"
|
|
40
|
+
:key="item.label"
|
|
41
|
+
class="flex items-center justify-between text-[11px]"
|
|
42
|
+
>
|
|
43
|
+
<span class="text-slate-400 dark:text-slate-500">{{ item.label }}</span>
|
|
44
|
+
<span class="font-mono tabular-nums font-medium" :class="color(item.value)">
|
|
45
|
+
{{ fmt(item.value) }}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
<template #footer>
|
|
54
54
|
<button
|
|
55
55
|
type="button"
|
|
56
|
-
class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150"
|
|
56
|
+
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 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
57
57
|
:class="t.ghostButton"
|
|
58
58
|
@click="handleCancel"
|
|
59
59
|
>
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
<button
|
|
63
63
|
type="button"
|
|
64
64
|
:disabled="!inputValue.trim() || submitting"
|
|
65
|
-
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"
|
|
65
|
+
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"
|
|
66
66
|
:class="[
|
|
67
67
|
!inputValue.trim() || submitting
|
|
68
68
|
? 'bg-emerald-400 cursor-not-allowed'
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
:type="type"
|
|
22
22
|
:disabled="disabled"
|
|
23
23
|
:title="title"
|
|
24
|
-
class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
|
24
|
+
class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
25
25
|
:class="variantClass"
|
|
26
26
|
@click="$emit('click', $event)"
|
|
27
27
|
>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<span v-if="hasAmount && shouldBeGreen" class="font-bold text-green-800">
|
|
2
|
+
<span v-if="hasAmount && shouldBeGreen" class="font-bold text-green-800 dark:text-green-400">
|
|
3
3
|
{{ amount }} <span v-if="showCurrency">€</span>
|
|
4
4
|
</span>
|
|
5
5
|
|
|
6
|
-
<span v-if="hasAmount && !shouldBeGreen" :class="[baseClasses, shouldBeRed ? 'text-red-800' : 'text-gray-800']">
|
|
6
|
+
<span v-if="hasAmount && !shouldBeGreen" :class="[baseClasses, shouldBeRed ? 'text-red-800 dark:text-red-400' : 'text-gray-800 dark:text-gray-200']">
|
|
7
7
|
{{ amount }} <span v-if="showCurrency">€</span>
|
|
8
8
|
</span>
|
|
9
9
|
</template>
|
|
@@ -8,7 +8,7 @@ function cellLabels(wrapper: ReturnType<typeof mount>): string[] {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
async function clickPage(wrapper: ReturnType<typeof mount>, page: number) {
|
|
11
|
-
const link = wrapper.findAll('
|
|
11
|
+
const link = wrapper.findAll('button').find(b => b.text().trim() === String(page))
|
|
12
12
|
expect(link, `expected a visible cell for page ${page}`).toBeTruthy()
|
|
13
13
|
await link!.trigger('click')
|
|
14
14
|
}
|
|
@@ -80,6 +80,6 @@ describe('Pagination', () => {
|
|
|
80
80
|
await clickPage(wrapper, 7)
|
|
81
81
|
await wrapper.setProps({ totalItems: 40 })
|
|
82
82
|
expect(cellLabels(wrapper)).toEqual(['1', '2'])
|
|
83
|
-
expect(wrapper.find('
|
|
83
|
+
expect(wrapper.find('button.active').text()).toBe('2')
|
|
84
84
|
})
|
|
85
85
|
})
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<nav aria-label="Pagination">
|
|
3
3
|
<ul class="flex list-none p-0 m-0">
|
|
4
4
|
<li v-for="(cell, index) in cells" :key="index" class="mr-1">
|
|
5
|
-
<
|
|
5
|
+
<button
|
|
6
6
|
v-if="cell !== 0"
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
type="button"
|
|
8
|
+
:aria-current="cell === currentPage ? 'page' : undefined"
|
|
9
|
+
:aria-label="`Go to page ${cell}`"
|
|
9
10
|
@click="changePage(cell)"
|
|
10
|
-
|
|
11
|
-
class="
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<span v-else class="inline-block border border-gray-300 rounded-lg p-3 text-black cursor-not-allowed">...</span>
|
|
11
|
+
class="inline-block min-w-[44px] border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-pointer transition-colors hover:bg-gray-300 dark:hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
12
|
+
:class="{ 'active font-bold bg-gray-200 dark:bg-gray-700': cell === currentPage }"
|
|
13
|
+
>{{ cell }}</button>
|
|
14
|
+
<span v-else aria-hidden="true" class="inline-block border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-not-allowed">...</span>
|
|
15
15
|
</li>
|
|
16
16
|
</ul>
|
|
17
|
-
</
|
|
17
|
+
</nav>
|
|
18
18
|
</template>
|
|
19
19
|
|
|
20
20
|
<script lang="ts" setup>
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { default as BaseDropdown } from './components/BaseDropdown.vue'
|
|
|
41
41
|
export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
|
|
42
42
|
export { default as BaseTable } from './components/BaseTable.vue'
|
|
43
43
|
export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
|
|
44
|
+
export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
|
|
44
45
|
|
|
45
46
|
// Composables
|
|
46
47
|
export { useTheme, initTheme } from './composables/useTheme'
|
|
@@ -77,6 +78,7 @@ export type { SegmentedOption } from './types/segmented'
|
|
|
77
78
|
export type { DropdownOption } from './types/dropdown'
|
|
78
79
|
export type { TableColumn } from './types/table'
|
|
79
80
|
export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
|
|
81
|
+
export type { StatBreakdownItem } from './types/statBreakdown'
|
|
80
82
|
|
|
81
83
|
// Utils
|
|
82
84
|
export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
@@ -95,6 +97,7 @@ export {
|
|
|
95
97
|
fmtPrice,
|
|
96
98
|
fmtPct,
|
|
97
99
|
fmtUsd,
|
|
100
|
+
fmtDuration,
|
|
98
101
|
} from './utils/format'
|
|
99
102
|
export { computePnL } from './utils/pnl'
|
|
100
103
|
export type { PnL, PnLInputs } from './utils/pnl'
|
package/src/style.css
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* Compile `dark:` variants against the `.dark` class that useTheme()/initTheme()
|
|
4
|
+
toggle on <html> — NOT the OS-level prefers-color-scheme media query
|
|
5
|
+
(Tailwind v4's default). Both consumers (TradeAutomation, WireMate) switch
|
|
6
|
+
themes with the class toggle, so media-gated dark rules leak the dark skin
|
|
7
|
+
into the light theme for users whose OS prefers dark.
|
|
8
|
+
Use a specificity-bearing selector (:is, not :where) so dark: utilities win
|
|
9
|
+
over plain base utilities like bg-slate-50 on a cascade tie. */
|
|
10
|
+
@custom-variant dark (&:is(.dark, .dark *));
|