mgv-backoffice 1.22.0 → 1.24.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 +38 -5
- package/dist/components/BaseDropdown.vue.d.ts +28 -0
- package/dist/components/EarningsCard.vue.d.ts +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/types/dropdown.d.ts +14 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +470 -395
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseDropdown.test.ts +64 -0
- package/src/components/BaseDropdown.vue +154 -0
- package/src/components/EarningsCard.test.ts +30 -0
- package/src/components/EarningsCard.vue +28 -11
- package/src/index.ts +2 -0
- package/src/types/dropdown.ts +14 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Button-style single-select dropdown ("Select Social User ⌄"). Unlike
|
|
4
|
+
BaseSelect (a themed native <select>), this renders a trigger button plus
|
|
5
|
+
a floating menu, so the closed control shows a placeholder and a chevron
|
|
6
|
+
that rotates while open — matching the app's filter dropdowns.
|
|
7
|
+
|
|
8
|
+
Options are passed as data (not <option> slots) so the menu rows can carry
|
|
9
|
+
tooltips and disabled state. Selecting a row emits its `value` and closes
|
|
10
|
+
the menu; Escape and an outside click also close it.
|
|
11
|
+
|
|
12
|
+
Same `dark:`-variant note as BaseSelect: the slate skin ships static
|
|
13
|
+
light/dark classes so consumers get both themes without extra config.
|
|
14
|
+
-->
|
|
15
|
+
<div
|
|
16
|
+
ref="rootEl"
|
|
17
|
+
:class="block ? 'relative w-full' : 'relative inline-block'"
|
|
18
|
+
>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
:class="[triggerClass, block ? 'w-full' : '']"
|
|
22
|
+
:disabled="disabled"
|
|
23
|
+
:aria-label="ariaLabel || undefined"
|
|
24
|
+
aria-haspopup="listbox"
|
|
25
|
+
:aria-expanded="open"
|
|
26
|
+
@click="toggle"
|
|
27
|
+
>
|
|
28
|
+
<span :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
|
|
29
|
+
{{ selectedOption ? selectedOption.label : placeholder }}
|
|
30
|
+
</span>
|
|
31
|
+
<ChevronDownIcon
|
|
32
|
+
class="w-5 h-5 shrink-0 text-slate-500 dark:text-slate-400 transition-transform"
|
|
33
|
+
:class="open ? 'rotate-180' : ''"
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
/>
|
|
36
|
+
</button>
|
|
37
|
+
|
|
38
|
+
<ul
|
|
39
|
+
v-if="open"
|
|
40
|
+
role="listbox"
|
|
41
|
+
: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"
|
|
43
|
+
>
|
|
44
|
+
<li
|
|
45
|
+
v-for="opt in options"
|
|
46
|
+
:key="String(opt.value)"
|
|
47
|
+
role="option"
|
|
48
|
+
:aria-selected="opt.value === modelValue"
|
|
49
|
+
:title="opt.title"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
:disabled="opt.disabled"
|
|
54
|
+
:class="rowClass(opt)"
|
|
55
|
+
@click="select(opt)"
|
|
56
|
+
>
|
|
57
|
+
{{ opt.label }}
|
|
58
|
+
</button>
|
|
59
|
+
</li>
|
|
60
|
+
</ul>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup lang="ts">
|
|
65
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
66
|
+
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
67
|
+
import { useEscapeKey } from '../composables/useEscapeKey'
|
|
68
|
+
import type { DropdownOption } from '../types/dropdown'
|
|
69
|
+
|
|
70
|
+
// NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
|
|
71
|
+
// emitted declarations, so consumers would see the erased constraint anyway.
|
|
72
|
+
// The update:modelValue payload is `any` so `v-model` on a union-typed ref
|
|
73
|
+
// type-checks; the actual payload is always one of the options' values.
|
|
74
|
+
interface Props {
|
|
75
|
+
options: readonly DropdownOption[]
|
|
76
|
+
modelValue?: string | number | null
|
|
77
|
+
/** Trigger text shown when nothing is selected. */
|
|
78
|
+
placeholder?: string
|
|
79
|
+
/** 'md' = px-4 py-2.5 (default, the app filter height); 'sm' = px-3 py-2. */
|
|
80
|
+
size?: 'sm' | 'md'
|
|
81
|
+
/** Full-width (w-full). Set false for an inline, content-width dropdown. */
|
|
82
|
+
block?: boolean
|
|
83
|
+
disabled?: boolean
|
|
84
|
+
/** Accessible name for the trigger/listbox when there is no visible label. */
|
|
85
|
+
ariaLabel?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
89
|
+
modelValue: null,
|
|
90
|
+
placeholder: 'Select',
|
|
91
|
+
size: 'md',
|
|
92
|
+
block: true,
|
|
93
|
+
disabled: false,
|
|
94
|
+
ariaLabel: '',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const emit = defineEmits<{
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
(e: 'update:modelValue', value: any): void
|
|
100
|
+
}>()
|
|
101
|
+
|
|
102
|
+
const open = ref(false)
|
|
103
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
104
|
+
|
|
105
|
+
const selectedOption = computed(() =>
|
|
106
|
+
props.options.find((o) => o.value === props.modelValue) ?? null,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const triggerClass = computed(() => {
|
|
110
|
+
const padding = props.size === 'sm' ? 'px-3 py-2' : 'px-4 py-2.5'
|
|
111
|
+
return (
|
|
112
|
+
'flex items-center justify-between gap-2 rounded-lg border text-left text-sm transition-colors ' +
|
|
113
|
+
'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
|
|
114
|
+
'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 ' +
|
|
116
|
+
padding
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
function rowClass(opt: DropdownOption): string {
|
|
121
|
+
const selected = opt.value === props.modelValue
|
|
122
|
+
const base =
|
|
123
|
+
'block w-full px-4 py-2 text-left text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
|
124
|
+
const fill = selected
|
|
125
|
+
? 'bg-emerald-500 text-white'
|
|
126
|
+
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
|
|
127
|
+
return `${base} ${fill}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toggle() {
|
|
131
|
+
if (props.disabled) return
|
|
132
|
+
open.value = !open.value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function select(opt: DropdownOption) {
|
|
136
|
+
if (opt.disabled) return
|
|
137
|
+
emit('update:modelValue', opt.value)
|
|
138
|
+
open.value = false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
useEscapeKey(() => {
|
|
142
|
+
open.value = false
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function onDocumentClick(e: MouseEvent) {
|
|
146
|
+
if (!open.value) return
|
|
147
|
+
if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
|
|
148
|
+
open.value = false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onMounted(() => document.addEventListener('click', onDocumentClick))
|
|
153
|
+
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
|
154
|
+
</script>
|
|
@@ -36,6 +36,36 @@ describe('EarningsCard', () => {
|
|
|
36
36
|
expect(w.html()).toContain('border-red-300')
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
describe('compact mode', () => {
|
|
40
|
+
it('keeps the large layout by default (backward compatibility)', () => {
|
|
41
|
+
const w = mount(EarningsCard, { props: { amount: 1 } })
|
|
42
|
+
expect(w.html()).toContain('p-6')
|
|
43
|
+
expect(w.html()).toContain('text-4xl')
|
|
44
|
+
expect(w.html()).not.toContain('text-lg')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders tighter padding and smaller type when compact', () => {
|
|
48
|
+
const w = mount(EarningsCard, { props: { amount: 1, compact: true } })
|
|
49
|
+
const html = w.html()
|
|
50
|
+
expect(html).toContain('p-3')
|
|
51
|
+
expect(html).toContain('text-lg')
|
|
52
|
+
expect(html).not.toContain('text-4xl')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('moves the trend glyph into the top-right corner when compact', () => {
|
|
56
|
+
const w = mount(EarningsCard, { props: { amount: 1, compact: true } })
|
|
57
|
+
expect(w.html()).toContain('absolute right-2 top-2')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('still applies the signed loss theme in compact mode', () => {
|
|
61
|
+
const w = mount(EarningsCard, {
|
|
62
|
+
props: { amount: -5, compact: true, signed: true, accent: 'emerald' },
|
|
63
|
+
})
|
|
64
|
+
expect(w.html()).toContain('border-red-300')
|
|
65
|
+
expect(w.html()).toContain('p-3')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
39
69
|
describe('signed (P&L) mode', () => {
|
|
40
70
|
// The "up-trend" arrowhead the default glyph uses; absent in the loss glyph.
|
|
41
71
|
const UP_GLYPH = 'points="16 7 22 7 22 13"'
|
|
@@ -29,6 +29,15 @@ interface Props {
|
|
|
29
29
|
* card for always-positive totals like lifetime earnings — are unaffected.
|
|
30
30
|
*/
|
|
31
31
|
signed?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Dense variant for dashboards that tile many cards on one row: tighter
|
|
34
|
+
* padding, smaller type, and the trend glyph shrunk into the top-right
|
|
35
|
+
* corner (absolutely positioned so the amount keeps the full card width —
|
|
36
|
+
* important for long values like 8-decimal BTC quantities). Don't combine
|
|
37
|
+
* with `badge`: both occupy the top-right corner. Defaults to `false` so
|
|
38
|
+
* existing consumers keep the large layout.
|
|
39
|
+
*/
|
|
40
|
+
compact?: boolean
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -40,6 +49,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
40
49
|
decimals: 2,
|
|
41
50
|
accent: 'orange',
|
|
42
51
|
signed: false,
|
|
52
|
+
compact: false,
|
|
43
53
|
})
|
|
44
54
|
|
|
45
55
|
const ACCENTS: Record<Accent, {
|
|
@@ -104,8 +114,8 @@ const formattedAmount = computed(
|
|
|
104
114
|
|
|
105
115
|
<template>
|
|
106
116
|
<div
|
|
107
|
-
class="relative rounded-xl border-2 border-dashed bg-white
|
|
108
|
-
:class="cls.border"
|
|
117
|
+
class="relative rounded-xl border-2 border-dashed bg-white dark:bg-slate-800"
|
|
118
|
+
:class="[cls.border, compact ? 'p-3' : 'p-6']"
|
|
109
119
|
>
|
|
110
120
|
<!-- Badge -->
|
|
111
121
|
<div
|
|
@@ -118,31 +128,38 @@ const formattedAmount = computed(
|
|
|
118
128
|
|
|
119
129
|
<!-- Header row -->
|
|
120
130
|
<div class="flex items-start justify-between">
|
|
121
|
-
<div>
|
|
122
|
-
<!-- Title
|
|
123
|
-
|
|
131
|
+
<div class="min-w-0">
|
|
132
|
+
<!-- Title: pr-8 in compact reserves only the corner the absolute icon
|
|
133
|
+
occupies — the amount below keeps the full card width so long
|
|
134
|
+
values (8-decimal BTC quantities) don't truncate. -->
|
|
135
|
+
<p
|
|
136
|
+
class="font-bold tracking-wide text-gray-800 dark:text-slate-200"
|
|
137
|
+
:class="compact ? 'pr-8 text-xs' : 'text-sm'"
|
|
138
|
+
>
|
|
124
139
|
{{ title }}
|
|
125
140
|
</p>
|
|
126
141
|
|
|
127
142
|
<!-- Amount -->
|
|
128
|
-
<p class="mt-1
|
|
143
|
+
<p class="mt-1 font-bold truncate" :class="[cls.amount, compact ? 'text-lg' : 'text-4xl']">
|
|
129
144
|
{{ formattedAmount }}
|
|
130
145
|
</p>
|
|
131
146
|
|
|
132
147
|
<!-- Subtitle -->
|
|
133
|
-
<p class="mt-1
|
|
148
|
+
<p class="mt-1 font-medium" :class="[cls.subtitle, compact ? 'text-xs' : 'text-sm']">
|
|
134
149
|
{{ subtitle }}
|
|
135
150
|
</p>
|
|
136
151
|
</div>
|
|
137
152
|
|
|
138
153
|
<!-- Chart icon -->
|
|
139
154
|
<div
|
|
140
|
-
class="
|
|
141
|
-
:class="
|
|
155
|
+
class="flex items-center justify-center"
|
|
156
|
+
:class="[
|
|
157
|
+
cls.iconBox,
|
|
158
|
+
compact ? 'absolute right-2 top-2 h-6 w-6 rounded-lg' : 'mt-6 h-12 w-12 rounded-xl',
|
|
159
|
+
]"
|
|
142
160
|
>
|
|
143
161
|
<svg
|
|
144
|
-
class="h-6 w-6"
|
|
145
|
-
:class="cls.icon"
|
|
162
|
+
:class="[cls.icon, compact ? 'h-3.5 w-3.5' : 'h-6 w-6']"
|
|
146
163
|
viewBox="0 0 24 24"
|
|
147
164
|
fill="none"
|
|
148
165
|
stroke="currentColor"
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export { default as BaseNotificationPanel } from './components/BaseNotificationP
|
|
|
37
37
|
// Components — extracted from TradeAutomation
|
|
38
38
|
export { default as BaseInput } from './components/BaseInput.vue'
|
|
39
39
|
export { default as BaseSelect } from './components/BaseSelect.vue'
|
|
40
|
+
export { default as BaseDropdown } from './components/BaseDropdown.vue'
|
|
40
41
|
export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
|
|
41
42
|
export { default as BaseTable } from './components/BaseTable.vue'
|
|
42
43
|
export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
|
|
@@ -73,6 +74,7 @@ export type { EntityPickerItem } from './types/entityPicker'
|
|
|
73
74
|
export type { LoginCredentials } from './types/auth'
|
|
74
75
|
export type { NotificationItem } from './types/notification'
|
|
75
76
|
export type { SegmentedOption } from './types/segmented'
|
|
77
|
+
export type { DropdownOption } from './types/dropdown'
|
|
76
78
|
export type { TableColumn } from './types/table'
|
|
77
79
|
export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
|
|
78
80
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One choice in a BaseDropdown. `value` is what the dropdown emits, `label`
|
|
3
|
+
* what the trigger and the menu row show; `title` becomes the row's tooltip
|
|
4
|
+
* and `disabled` makes the row unselectable.
|
|
5
|
+
*
|
|
6
|
+
* Generic so callers with union-typed values ('7D' | '30D' | …) keep full
|
|
7
|
+
* type-safety through v-model.
|
|
8
|
+
*/
|
|
9
|
+
export interface DropdownOption<T extends string | number = string | number> {
|
|
10
|
+
value: T
|
|
11
|
+
label: string
|
|
12
|
+
title?: string
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
}
|