mgv-backoffice 1.25.0 → 1.28.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 +70 -6
- package/dist/components/BaseActionButton.vue.d.ts +1 -1
- package/dist/components/BaseAlert.vue.d.ts +21 -25
- package/dist/components/BaseChipButton.vue.d.ts +25 -0
- package/dist/components/BaseRemoveButton.vue.d.ts +7 -0
- package/dist/components/BaseStatBreakdown.vue.d.ts +14 -0
- package/dist/components/BaseStatusPill.vue.d.ts +8 -0
- package/dist/components/BaseToolbarButton.vue.d.ts +1 -1
- package/dist/components/EarningsCard.vue.d.ts +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/types/statBreakdown.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +528 -428
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +7 -0
- package/package.json +54 -54
- package/src/components/BaseAlert.vue +78 -32
- package/src/components/BaseChipButton.vue +42 -0
- package/src/components/BaseCollapsibleSection.vue +9 -3
- package/src/components/BaseDropdown.vue +4 -2
- package/src/components/BaseRemoveButton.vue +22 -0
- package/src/components/BaseStatBreakdown.test.ts +56 -0
- package/src/components/BaseStatBreakdown.vue +49 -0
- package/src/components/BaseStatusPill.vue +54 -0
- package/src/index.ts +6 -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.28.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
|
+
}
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
<!--
|
|
3
|
+
Inline notice panel (error / warning / success / info).
|
|
4
|
+
|
|
5
|
+
v2 (1.27.0): rewritten from the legacy dynamic-class version —
|
|
6
|
+
• theme-aware via the shared `isDark` ref (same pattern as the other
|
|
7
|
+
extracted components; no runtime-composed class names, so every
|
|
8
|
+
class survives Tailwind's static scan)
|
|
9
|
+
• optional default slot for body content below the title
|
|
10
|
+
• `compact` variant for slim in-form warnings (text-xs, small icon)
|
|
11
|
+
Backward compatible: `<BaseAlert title="..." :color="AlertEnum.X" />`
|
|
12
|
+
renders the same single-line alert as before, mb-4 included.
|
|
13
|
+
-->
|
|
14
|
+
<div
|
|
15
|
+
role="alert"
|
|
16
|
+
class="flex gap-3 mb-4 rounded-lg border text-sm transition-colors"
|
|
17
|
+
:class="[compact ? 'items-start px-3 py-2 text-xs' : 'items-start p-4', palette]"
|
|
18
|
+
>
|
|
19
|
+
<component
|
|
20
|
+
:is="iconComponent"
|
|
21
|
+
class="shrink-0"
|
|
22
|
+
:class="[compact ? 'w-4 h-4 mt-px' : 'w-5 h-5 mt-0.5', iconColorClass]"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
/>
|
|
25
|
+
<div class="min-w-0 flex-1">
|
|
26
|
+
<p v-if="title" class="font-medium">{{ title }}</p>
|
|
27
|
+
<!-- Body — caller-styled free-form content under the title. -->
|
|
28
|
+
<slot />
|
|
6
29
|
</div>
|
|
7
30
|
</div>
|
|
8
31
|
</template>
|
|
@@ -14,45 +37,68 @@ import {
|
|
|
14
37
|
ExclamationTriangleIcon,
|
|
15
38
|
InformationCircleIcon,
|
|
16
39
|
} from '@heroicons/vue/24/outline'
|
|
17
|
-
import { AlertEnum } from
|
|
18
|
-
import { computed
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
import { AlertEnum } from '../enums/AlertEnum'
|
|
41
|
+
import { computed } from 'vue'
|
|
42
|
+
import { useTheme } from '../composables/useTheme'
|
|
43
|
+
|
|
44
|
+
interface Props {
|
|
45
|
+
/** Bold headline. Optional when the slot carries the whole message. */
|
|
46
|
+
title?: string
|
|
47
|
+
/** Alert mode — drives palette and icon. Defaults to ERROR. */
|
|
48
|
+
color?: AlertEnum
|
|
49
|
+
/** Slim variant for in-form warnings: text-xs, smaller icon/padding. */
|
|
50
|
+
compact?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
54
|
+
title: '',
|
|
55
|
+
color: AlertEnum.ERROR,
|
|
56
|
+
compact: false,
|
|
32
57
|
})
|
|
33
58
|
|
|
59
|
+
const { isDark } = useTheme()
|
|
60
|
+
|
|
61
|
+
// Literal palette table so Tailwind's scanner sees every class.
|
|
62
|
+
const PALETTES: Record<AlertEnum, { light: string; dark: string }> = {
|
|
63
|
+
[AlertEnum.ERROR]: {
|
|
64
|
+
light: 'bg-red-50 border-red-200 text-red-700',
|
|
65
|
+
dark: 'bg-red-500/10 border-red-500/30 text-red-300',
|
|
66
|
+
},
|
|
67
|
+
[AlertEnum.WARNING]: {
|
|
68
|
+
light: 'bg-amber-50 border-amber-200 text-amber-700',
|
|
69
|
+
dark: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
|
|
70
|
+
},
|
|
71
|
+
[AlertEnum.SUCCESS]: {
|
|
72
|
+
light: 'bg-emerald-50 border-emerald-200 text-emerald-700',
|
|
73
|
+
dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
|
|
74
|
+
},
|
|
75
|
+
[AlertEnum.INFO]: {
|
|
76
|
+
light: 'bg-sky-50 border-sky-200 text-sky-700',
|
|
77
|
+
dark: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const palette = computed(() =>
|
|
82
|
+
isDark.value ? PALETTES[props.color].dark : PALETTES[props.color].light,
|
|
83
|
+
)
|
|
84
|
+
|
|
34
85
|
// Icon + tint follow the alert mode so an error doesn't render with a
|
|
35
86
|
// neutral info glyph.
|
|
36
87
|
const iconComponent = computed(() => {
|
|
37
88
|
switch (props.color) {
|
|
38
|
-
case AlertEnum.SUCCESS: return CheckCircleIcon
|
|
39
|
-
case AlertEnum.WARNING: return ExclamationTriangleIcon
|
|
40
|
-
case AlertEnum.INFO: return InformationCircleIcon
|
|
41
|
-
default: return ExclamationCircleIcon
|
|
89
|
+
case AlertEnum.SUCCESS: return CheckCircleIcon
|
|
90
|
+
case AlertEnum.WARNING: return ExclamationTriangleIcon
|
|
91
|
+
case AlertEnum.INFO: return InformationCircleIcon
|
|
92
|
+
default: return ExclamationCircleIcon
|
|
42
93
|
}
|
|
43
94
|
})
|
|
44
95
|
|
|
45
96
|
const iconColorClass = computed(() => {
|
|
46
97
|
switch (props.color) {
|
|
47
|
-
case AlertEnum.SUCCESS: return 'text-
|
|
48
|
-
case AlertEnum.WARNING: return 'text-
|
|
49
|
-
case AlertEnum.INFO: return 'text-
|
|
50
|
-
default: return 'text-red-500'
|
|
98
|
+
case AlertEnum.SUCCESS: return 'text-emerald-500'
|
|
99
|
+
case AlertEnum.WARNING: return 'text-amber-500'
|
|
100
|
+
case AlertEnum.INFO: return 'text-sky-500'
|
|
101
|
+
default: return 'text-red-500'
|
|
51
102
|
}
|
|
52
103
|
})
|
|
53
|
-
|
|
54
|
-
function computeCss() {
|
|
55
|
-
let baseColor = getBaseColor(props.color);
|
|
56
|
-
return "flex items-center p-4 mb-4 text-sm text-" + baseColor + "-800 border border-" + baseColor + "-300 rounded-lg bg-" + baseColor + "-50 dark:bg-gray-800 dark:text-" + baseColor + "-400 dark:border-" + baseColor + "-800"
|
|
57
|
-
}
|
|
58
104
|
</script>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Small tinted "chip" action button — the compact emerald "+ Add" pill
|
|
4
|
+
used above repeatable form rows (extracted from WireMate's CreateMock,
|
|
5
|
+
where the identical class string appeared seven times).
|
|
6
|
+
|
|
7
|
+
Label comes from the default slot so callers can mix text and icons.
|
|
8
|
+
-->
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
:disabled="disabled"
|
|
12
|
+
class="inline-flex items-center gap-1 font-medium rounded-md border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
13
|
+
:class="[
|
|
14
|
+
size === 'xs' ? 'text-xs px-2 py-0.5' : 'text-xs px-2.5 py-1',
|
|
15
|
+
isDark
|
|
16
|
+
? 'text-emerald-300 border-emerald-700 bg-emerald-900/30 hover:bg-emerald-800/50'
|
|
17
|
+
: 'text-emerald-700 border-emerald-200 bg-emerald-50 hover:bg-emerald-100',
|
|
18
|
+
]"
|
|
19
|
+
@click="emit('click')"
|
|
20
|
+
>
|
|
21
|
+
<slot />
|
|
22
|
+
</button>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script lang="ts" setup>
|
|
26
|
+
import { useTheme } from '../composables/useTheme'
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
/** 'sm' = px-2.5 py-1 (default); 'xs' = px-2 py-0.5 for tight corners. */
|
|
30
|
+
size?: 'xs' | 'sm'
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
withDefaults(defineProps<Props>(), {
|
|
35
|
+
size: 'sm',
|
|
36
|
+
disabled: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{ click: [] }>()
|
|
40
|
+
|
|
41
|
+
const { isDark } = useTheme()
|
|
42
|
+
</script>
|
|
@@ -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
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"
|
|
25
|
-
:class="headerClasses"
|
|
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 />
|
|
@@ -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"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The "×" remove-row affordance used beside repeatable form rows
|
|
4
|
+
(extracted from WireMate's CreateMock, where the identical red ×
|
|
5
|
+
button appeared on every key/value, matcher, and cookie row).
|
|
6
|
+
|
|
7
|
+
Renders a multiplication sign; name it for screen readers via
|
|
8
|
+
`aria-label` (falls through as an attr, as do `title`, `disabled`,
|
|
9
|
+
and any extra classes like `pt-1` / `self-start`).
|
|
10
|
+
-->
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
class="px-2 text-lg leading-none text-red-400 hover:text-red-600 transition-colors cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:text-gray-400"
|
|
14
|
+
@click="emit('click')"
|
|
15
|
+
>
|
|
16
|
+
×
|
|
17
|
+
</button>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script lang="ts" setup>
|
|
21
|
+
const emit = defineEmits<{ click: [] }>()
|
|
22
|
+
</script>
|
|
@@ -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>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Connection/health status pill: a colored dot (pulsing while ok) next
|
|
4
|
+
to a short label, on a tinted rounded background. Extracted from
|
|
5
|
+
WireMate's sidebar WireMock health indicator.
|
|
6
|
+
|
|
7
|
+
status: 'ok' → emerald + pulse, 'error' → red, 'unknown' → gray.
|
|
8
|
+
-->
|
|
9
|
+
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" :class="pillClass">
|
|
10
|
+
<span class="relative flex h-2 w-2">
|
|
11
|
+
<span
|
|
12
|
+
v-if="status === 'ok'"
|
|
13
|
+
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
></span>
|
|
16
|
+
<span class="relative inline-flex rounded-full h-2 w-2" :class="dotClass"></span>
|
|
17
|
+
</span>
|
|
18
|
+
{{ label }}
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script lang="ts" setup>
|
|
23
|
+
import { computed } from 'vue'
|
|
24
|
+
import { useTheme } from '../composables/useTheme'
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
status: 'ok' | 'error' | 'unknown'
|
|
28
|
+
/** Short text next to the dot, e.g. "WireMock Connected". */
|
|
29
|
+
label: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const props = defineProps<Props>()
|
|
33
|
+
|
|
34
|
+
const { isDark } = useTheme()
|
|
35
|
+
|
|
36
|
+
const pillClass = computed(() => {
|
|
37
|
+
switch (props.status) {
|
|
38
|
+
case 'ok':
|
|
39
|
+
return isDark.value ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700'
|
|
40
|
+
case 'error':
|
|
41
|
+
return isDark.value ? 'bg-red-500/10 text-red-400' : 'bg-red-50 text-red-700'
|
|
42
|
+
default:
|
|
43
|
+
return isDark.value ? 'bg-gray-700 text-gray-400' : 'bg-gray-100 text-gray-500'
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const dotClass = computed(() => {
|
|
48
|
+
switch (props.status) {
|
|
49
|
+
case 'ok': return 'bg-emerald-500'
|
|
50
|
+
case 'error': return 'bg-red-500'
|
|
51
|
+
default: return 'bg-gray-400'
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
</script>
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,9 @@ export { default as BaseCopyButton } from './components/BaseCopyButton.vue'
|
|
|
33
33
|
export { default as BaseGoogleSignInButton } from './components/BaseGoogleSignInButton.vue'
|
|
34
34
|
export { default as BaseLoginForm } from './components/BaseLoginForm.vue'
|
|
35
35
|
export { default as BaseNotificationPanel } from './components/BaseNotificationPanel.vue'
|
|
36
|
+
export { default as BaseChipButton } from './components/BaseChipButton.vue'
|
|
37
|
+
export { default as BaseRemoveButton } from './components/BaseRemoveButton.vue'
|
|
38
|
+
export { default as BaseStatusPill } from './components/BaseStatusPill.vue'
|
|
36
39
|
|
|
37
40
|
// Components — extracted from TradeAutomation
|
|
38
41
|
export { default as BaseInput } from './components/BaseInput.vue'
|
|
@@ -41,6 +44,7 @@ export { default as BaseDropdown } from './components/BaseDropdown.vue'
|
|
|
41
44
|
export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
|
|
42
45
|
export { default as BaseTable } from './components/BaseTable.vue'
|
|
43
46
|
export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
|
|
47
|
+
export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
|
|
44
48
|
|
|
45
49
|
// Composables
|
|
46
50
|
export { useTheme, initTheme } from './composables/useTheme'
|
|
@@ -77,6 +81,7 @@ export type { SegmentedOption } from './types/segmented'
|
|
|
77
81
|
export type { DropdownOption } from './types/dropdown'
|
|
78
82
|
export type { TableColumn } from './types/table'
|
|
79
83
|
export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
|
|
84
|
+
export type { StatBreakdownItem } from './types/statBreakdown'
|
|
80
85
|
|
|
81
86
|
// Utils
|
|
82
87
|
export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
@@ -95,6 +100,7 @@ export {
|
|
|
95
100
|
fmtPrice,
|
|
96
101
|
fmtPct,
|
|
97
102
|
fmtUsd,
|
|
103
|
+
fmtDuration,
|
|
98
104
|
} from './utils/format'
|
|
99
105
|
export { computePnL } from './utils/pnl'
|
|
100
106
|
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 *));
|
package/src/utils/format.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fmtPrice,
|
|
8
8
|
fmtPct,
|
|
9
9
|
fmtUsd,
|
|
10
|
+
fmtDuration,
|
|
10
11
|
} from './format'
|
|
11
12
|
|
|
12
13
|
const EM_DASH = '—'
|
|
@@ -127,3 +128,37 @@ describe('fmtUsd', () => {
|
|
|
127
128
|
expect(fmtUsd(0)).toBe('+$0.00')
|
|
128
129
|
})
|
|
129
130
|
})
|
|
131
|
+
|
|
132
|
+
describe('fmtDuration', () => {
|
|
133
|
+
it('renders an em-dash for null/undefined/non-finite input', () => {
|
|
134
|
+
expect(fmtDuration(null)).toBe(EM_DASH)
|
|
135
|
+
expect(fmtDuration(undefined)).toBe(EM_DASH)
|
|
136
|
+
expect(fmtDuration(NaN)).toBe(EM_DASH)
|
|
137
|
+
expect(fmtDuration(Infinity)).toBe(EM_DASH)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('drops zero-value leading units', () => {
|
|
141
|
+
expect(fmtDuration(12 * 60_000)).toBe('12m')
|
|
142
|
+
expect(fmtDuration((5 * 60 + 12) * 60_000)).toBe('5h 12m')
|
|
143
|
+
expect(fmtDuration((3 * 1440 + 5 * 60 + 12) * 60_000)).toBe('3d 5h')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('caps the rendered units at maxUnits', () => {
|
|
147
|
+
const ms = (2 * 1440 + 5 * 60 + 13) * 60_000
|
|
148
|
+
expect(fmtDuration(ms, 3)).toBe('2d 5h 13m')
|
|
149
|
+
expect(fmtDuration(ms, 2)).toBe('2d 5h')
|
|
150
|
+
expect(fmtDuration(ms, 1)).toBe('2d')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('keeps the hours unit when days are present even if hours are zero', () => {
|
|
154
|
+
expect(fmtDuration(2 * 1440 * 60_000, 3)).toBe('2d 0h 0m')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('clamps negative durations to zero', () => {
|
|
158
|
+
expect(fmtDuration(-5000)).toBe('0m')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('renders sub-minute durations as 0m', () => {
|
|
162
|
+
expect(fmtDuration(59_000)).toBe('0m')
|
|
163
|
+
})
|
|
164
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -87,3 +87,22 @@ export function fmtUsd(v: number): string {
|
|
|
87
87
|
const sign = v < 0 ? '-' : '+'
|
|
88
88
|
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
93
|
+
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
94
|
+
* `maxUnits` caps how many units render (2 → "3d 5h", 3 → "3d 5h 12m").
|
|
95
|
+
* Renders an em-dash for null/undefined/non-finite input.
|
|
96
|
+
*/
|
|
97
|
+
export function fmtDuration(ms: number | null | undefined, maxUnits = 2): string {
|
|
98
|
+
if (ms === null || ms === undefined || !isFinite(ms)) return EM_DASH
|
|
99
|
+
const totalMins = Math.floor(Math.max(0, ms) / 60_000)
|
|
100
|
+
const days = Math.floor(totalMins / 1440)
|
|
101
|
+
const hours = Math.floor((totalMins % 1440) / 60)
|
|
102
|
+
const mins = totalMins % 60
|
|
103
|
+
const parts: string[] = []
|
|
104
|
+
if (days > 0) parts.push(`${days}d`)
|
|
105
|
+
if (days > 0 || hours > 0) parts.push(`${hours}h`)
|
|
106
|
+
parts.push(`${mins}m`)
|
|
107
|
+
return parts.slice(0, maxUnits).join(' ')
|
|
108
|
+
}
|