mgv-backoffice 1.21.0 → 1.23.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 +129 -0
- package/dist/components/BaseInput.vue.d.ts +21 -0
- package/dist/components/BaseSegmentedControl.vue.d.ts +25 -0
- package/dist/components/BaseSelect.vue.d.ts +28 -0
- package/dist/components/BaseSpecFields.vue.d.ts +23 -0
- package/dist/components/BaseTable.vue.d.ts +26 -0
- package/dist/components/EarningsCard.vue.d.ts +10 -0
- package/dist/index.d.ts +8 -0
- package/dist/types/segmented.d.ts +11 -0
- package/dist/types/specField.d.ts +23 -0
- package/dist/types/table.d.ts +8 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +422 -258
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseInput.test.ts +40 -0
- package/src/components/BaseInput.vue +46 -0
- package/src/components/BaseSegmentedControl.test.ts +78 -0
- package/src/components/BaseSegmentedControl.vue +98 -0
- package/src/components/BaseSelect.test.ts +37 -0
- package/src/components/BaseSelect.vue +46 -0
- package/src/components/BaseSpecFields.test.ts +78 -0
- package/src/components/BaseSpecFields.vue +83 -0
- package/src/components/BaseTable.test.ts +55 -0
- package/src/components/BaseTable.vue +50 -0
- package/src/components/EarningsCard.test.ts +30 -0
- package/src/components/EarningsCard.vue +28 -11
- package/src/index.ts +10 -0
- package/src/types/segmented.ts +11 -0
- package/src/types/specField.ts +25 -0
- package/src/types/table.ts +8 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseSegmentedControl from './BaseSegmentedControl.vue'
|
|
4
|
+
import type { SegmentedOption } from '../types/segmented'
|
|
5
|
+
|
|
6
|
+
const options: SegmentedOption[] = [
|
|
7
|
+
{ value: 'ALL', label: 'All' },
|
|
8
|
+
{ value: 'STOCK', label: 'Stock' },
|
|
9
|
+
{ value: 'CRYPTO', label: 'Crypto', title: 'Crypto pairs' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseSegmentedControl', () => {
|
|
13
|
+
it('renders one button per option and marks the selected one', () => {
|
|
14
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
15
|
+
props: { options, modelValue: 'STOCK' },
|
|
16
|
+
})
|
|
17
|
+
const buttons = wrapper.findAll('button')
|
|
18
|
+
expect(buttons.map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
|
|
19
|
+
expect(buttons[1].attributes('aria-pressed')).toBe('true')
|
|
20
|
+
expect(buttons[0].attributes('aria-pressed')).toBe('false')
|
|
21
|
+
expect(buttons[1].classes()).toContain('bg-emerald-500')
|
|
22
|
+
expect(buttons[0].classes()).toContain('bg-slate-50')
|
|
23
|
+
expect(buttons[2].attributes('title')).toBe('Crypto pairs')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('emits update:modelValue with the clicked option value', async () => {
|
|
27
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
28
|
+
props: { options, modelValue: 'ALL' },
|
|
29
|
+
})
|
|
30
|
+
await wrapper.findAll('button')[2].trigger('click')
|
|
31
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([['CRYPTO']])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('uses the emerald-600 fill and wider padding in the wide variant', () => {
|
|
35
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
36
|
+
props: { options, modelValue: 'ALL', variant: 'wide' },
|
|
37
|
+
})
|
|
38
|
+
const active = wrapper.findAll('button')[0]
|
|
39
|
+
expect(active.classes()).toContain('bg-emerald-600')
|
|
40
|
+
expect(active.classes()).toContain('px-4')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders the toolbar variant with h-9 divide-x wrapper and uppercase buttons', () => {
|
|
44
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
45
|
+
props: { options, modelValue: 'ALL', variant: 'toolbar' },
|
|
46
|
+
})
|
|
47
|
+
expect(wrapper.find('div').classes()).toContain('h-9')
|
|
48
|
+
expect(wrapper.find('div').classes()).toContain('divide-x')
|
|
49
|
+
expect(wrapper.findAll('button')[0].classes()).toContain('uppercase')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('announces itself as a labelled group only when ariaLabel is set', () => {
|
|
53
|
+
const labelled = mount(BaseSegmentedControl, {
|
|
54
|
+
props: { options, modelValue: 'ALL', ariaLabel: 'Exchange' },
|
|
55
|
+
})
|
|
56
|
+
expect(labelled.find('div').attributes('role')).toBe('group')
|
|
57
|
+
expect(labelled.find('div').attributes('aria-label')).toBe('Exchange')
|
|
58
|
+
|
|
59
|
+
const bare = mount(BaseSegmentedControl, { props: { options, modelValue: 'ALL' } })
|
|
60
|
+
expect(bare.find('div').attributes('role')).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('lets optionClass override the fill classes per option', () => {
|
|
64
|
+
const wrapper = mount(BaseSegmentedControl, {
|
|
65
|
+
props: {
|
|
66
|
+
options,
|
|
67
|
+
modelValue: 'ALL',
|
|
68
|
+
optionClass: (opt: SegmentedOption, active: boolean) =>
|
|
69
|
+
active ? 'bg-red-500 text-white' : 'text-red-600',
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
const buttons = wrapper.findAll('button')
|
|
73
|
+
expect(buttons[0].classes()).toContain('bg-red-500')
|
|
74
|
+
expect(buttons[1].classes()).toContain('text-red-600')
|
|
75
|
+
// Layout classes stay owned by the variant.
|
|
76
|
+
expect(buttons[0].classes()).toContain('px-3')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Segmented button group ("Class: All | Stock | Crypto"). One button per
|
|
4
|
+
option; the selected one gets the filled treatment and aria-pressed.
|
|
5
|
+
|
|
6
|
+
Variants:
|
|
7
|
+
• base — px-3 py-2 text-sm buttons, emerald-500 active fill
|
|
8
|
+
• wide — px-4 py-2 buttons, emerald-600 active fill
|
|
9
|
+
• toolbar — h-9 uppercase text-xs buttons with focus-visible rings
|
|
10
|
+
(divide-x separators instead of border-l)
|
|
11
|
+
|
|
12
|
+
`optionClass` overrides the per-button fill classes (e.g. severity
|
|
13
|
+
colours per option); layout classes stay owned by the variant.
|
|
14
|
+
Same `dark:`-variant note as BaseInput.
|
|
15
|
+
-->
|
|
16
|
+
<div
|
|
17
|
+
:role="ariaLabel ? 'group' : undefined"
|
|
18
|
+
:aria-label="ariaLabel || undefined"
|
|
19
|
+
:class="wrapClass"
|
|
20
|
+
>
|
|
21
|
+
<button
|
|
22
|
+
v-for="opt in options"
|
|
23
|
+
:key="String(opt.value)"
|
|
24
|
+
type="button"
|
|
25
|
+
:class="[buttonClass, fillClass(opt)]"
|
|
26
|
+
:aria-pressed="opt.value === modelValue"
|
|
27
|
+
:title="opt.title"
|
|
28
|
+
@click="emit('update:modelValue', opt.value)"
|
|
29
|
+
>
|
|
30
|
+
{{ opt.label }}
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { computed } from 'vue'
|
|
37
|
+
import type { SegmentedOption } from '../types/segmented'
|
|
38
|
+
|
|
39
|
+
// NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
|
|
40
|
+
// emitted declarations, so consumers would see the erased constraint anyway.
|
|
41
|
+
// The update:modelValue payload is `any` so `v-model` on a union-typed ref
|
|
42
|
+
// ('all' | 'STOCK' | …) type-checks; the actual payload is always one of the
|
|
43
|
+
// options' values.
|
|
44
|
+
interface Props {
|
|
45
|
+
options: readonly SegmentedOption[]
|
|
46
|
+
modelValue: string | number
|
|
47
|
+
variant?: 'base' | 'wide' | 'toolbar'
|
|
48
|
+
/** When set, the wrapper announces itself as a labelled group. */
|
|
49
|
+
ariaLabel?: string
|
|
50
|
+
/**
|
|
51
|
+
* Override the fill (colour) classes per button — receives the option and
|
|
52
|
+
* whether it is the selected one. Defaults to the variant's emerald/slate
|
|
53
|
+
* treatment.
|
|
54
|
+
*/
|
|
55
|
+
optionClass?: (option: SegmentedOption, active: boolean) => string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
59
|
+
variant: 'base',
|
|
60
|
+
ariaLabel: '',
|
|
61
|
+
optionClass: undefined,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const emit = defineEmits<{
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
(e: 'update:modelValue', value: any): void
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
const wrapClass = computed(() =>
|
|
70
|
+
props.variant === 'toolbar'
|
|
71
|
+
? 'inline-flex h-9 divide-x divide-slate-300 dark:divide-slate-600 rounded-md border border-slate-300 dark:border-slate-600 overflow-hidden'
|
|
72
|
+
: 'inline-flex rounded border border-slate-300 dark:border-slate-600 overflow-hidden',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const buttonClass = computed(() => {
|
|
76
|
+
if (props.variant === 'toolbar') {
|
|
77
|
+
return (
|
|
78
|
+
'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors ' +
|
|
79
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500'
|
|
80
|
+
)
|
|
81
|
+
}
|
|
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`
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const activeFill = computed(() =>
|
|
87
|
+
props.variant === 'wide' ? 'bg-emerald-600 text-white' : 'bg-emerald-500 text-white',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const idleFill =
|
|
91
|
+
'bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
|
92
|
+
|
|
93
|
+
function fillClass(opt: SegmentedOption): string {
|
|
94
|
+
const active = opt.value === props.modelValue
|
|
95
|
+
if (props.optionClass) return props.optionClass(opt, active)
|
|
96
|
+
return active ? activeFill.value : idleFill
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseSelect from './BaseSelect.vue'
|
|
4
|
+
|
|
5
|
+
const slots = {
|
|
6
|
+
default: '<option value="a">A</option><option value="b">B</option>',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('BaseSelect', () => {
|
|
10
|
+
it('renders the field skin, full-width compact by default', () => {
|
|
11
|
+
const wrapper = mount(BaseSelect, { slots })
|
|
12
|
+
const select = wrapper.find('select')
|
|
13
|
+
expect(select.classes()).toEqual(
|
|
14
|
+
expect.arrayContaining(['rounded', 'border', 'w-full', 'px-2', 'py-1.5', 'text-sm']),
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('supports md size and inline (non-block) layout', () => {
|
|
19
|
+
const wrapper = mount(BaseSelect, { props: { size: 'md', block: false }, slots })
|
|
20
|
+
const select = wrapper.find('select')
|
|
21
|
+
expect(select.classes()).toEqual(expect.arrayContaining(['px-3', 'py-2']))
|
|
22
|
+
expect(select.classes()).not.toContain('w-full')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reflects modelValue and emits the selected value on change', async () => {
|
|
26
|
+
const wrapper = mount(BaseSelect, { props: { modelValue: 'a' }, slots })
|
|
27
|
+
const select = wrapper.find('select')
|
|
28
|
+
expect((select.element as HTMLSelectElement).value).toBe('a')
|
|
29
|
+
await select.setValue('b')
|
|
30
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([['b']])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders caller-provided options via the default slot', () => {
|
|
34
|
+
const wrapper = mount(BaseSelect, { slots })
|
|
35
|
+
expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['A', 'B'])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Themed select sharing BaseInput's field skin. Options come from the
|
|
4
|
+
default slot so callers keep full control of <option> rendering (values,
|
|
5
|
+
titles, disabled entries). Attrs (id, disabled, extra classes) fall
|
|
6
|
+
through. Same `dark:`-variant note as BaseInput.
|
|
7
|
+
-->
|
|
8
|
+
<select
|
|
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"
|
|
11
|
+
:class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
|
|
12
|
+
@change="onChange"
|
|
13
|
+
>
|
|
14
|
+
<slot />
|
|
15
|
+
</select>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
// String-valued only — a DOM select always yields strings; use v-model.number
|
|
20
|
+
// semantics in the owner if needed. The update:modelValue payload is `any`
|
|
21
|
+
// (not generic: vite-plugin-dts flattens SFC generics out of the emitted
|
|
22
|
+
// declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
|
|
23
|
+
// type-checks; the actual payload is always the selected option's string.
|
|
24
|
+
interface Props {
|
|
25
|
+
modelValue?: string | null
|
|
26
|
+
/** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
|
|
27
|
+
size?: 'sm' | 'md'
|
|
28
|
+
/** Full-width (w-full). Set false for inline selects. */
|
|
29
|
+
block?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
withDefaults(defineProps<Props>(), {
|
|
33
|
+
modelValue: undefined,
|
|
34
|
+
size: 'sm',
|
|
35
|
+
block: true,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
(e: 'update:modelValue', value: any): void
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
function onChange(e: Event) {
|
|
44
|
+
emit('update:modelValue', (e.target as HTMLSelectElement).value)
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseSpecFields from './BaseSpecFields.vue'
|
|
4
|
+
import type { SpecField } from '../types/specField'
|
|
5
|
+
|
|
6
|
+
const specs: SpecField[] = [
|
|
7
|
+
{ key: 'mode', label: 'Mode', type: 'select', options: ['fast', 'slow'] },
|
|
8
|
+
{ key: 'enabled', label: 'Enabled', type: 'boolean' },
|
|
9
|
+
{ key: 'threshold', label: 'Threshold', type: 'decimal', min: 0, help: 'Trigger level.' },
|
|
10
|
+
{ key: 'lookback', label: 'Lookback', type: 'integer' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const params = { mode: 'fast', enabled: true, threshold: 1.5, lookback: 10 }
|
|
14
|
+
|
|
15
|
+
function mountFields(extra = {}) {
|
|
16
|
+
return mount(BaseSpecFields, { props: { specs, params }, ...extra })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('BaseSpecFields', () => {
|
|
20
|
+
it('renders a select, a checkbox and number inputs per spec type', () => {
|
|
21
|
+
const wrapper = mountFields()
|
|
22
|
+
expect(wrapper.findAll('select')).toHaveLength(1)
|
|
23
|
+
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(1)
|
|
24
|
+
expect(wrapper.findAll('input[type="number"]')).toHaveLength(2)
|
|
25
|
+
expect(wrapper.text()).toContain('Trigger level.')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('defaults numeric steps to 1 for integers and 0.01 for decimals', () => {
|
|
29
|
+
const inputs = mountFields().findAll('input[type="number"]')
|
|
30
|
+
expect(inputs[0].attributes('step')).toBe('0.01')
|
|
31
|
+
expect(inputs[1].attributes('step')).toBe('1')
|
|
32
|
+
expect(inputs[0].attributes('min')).toBe('0')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('emits update(key, value) for select changes', async () => {
|
|
36
|
+
const wrapper = mountFields()
|
|
37
|
+
await wrapper.find('select').setValue('slow')
|
|
38
|
+
expect(wrapper.emitted('update')).toEqual([['mode', 'slow']])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('emits update(key, checked) for checkbox toggles', async () => {
|
|
42
|
+
const wrapper = mountFields()
|
|
43
|
+
await wrapper.find('input[type="checkbox"]').setValue(false)
|
|
44
|
+
expect(wrapper.emitted('update')).toEqual([['enabled', false]])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('parses numeric input, passing unparseable values through raw', async () => {
|
|
48
|
+
const wrapper = mountFields()
|
|
49
|
+
const threshold = wrapper.findAll('input[type="number"]')[0]
|
|
50
|
+
await threshold.setValue('2.25')
|
|
51
|
+
await threshold.setValue('')
|
|
52
|
+
expect(wrapper.emitted('update')).toEqual([
|
|
53
|
+
['threshold', 2.25],
|
|
54
|
+
['threshold', ''],
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('never mutates params directly', async () => {
|
|
59
|
+
const frozen = Object.freeze({ ...params })
|
|
60
|
+
const wrapper = mount(BaseSpecFields, { props: { specs, params: frozen } })
|
|
61
|
+
await wrapper.find('select').setValue('slow')
|
|
62
|
+
expect(frozen.mode).toBe('fast')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('exposes each spec to the after slot', () => {
|
|
66
|
+
const wrapper = mountFields({
|
|
67
|
+
slots: {
|
|
68
|
+
after: `<template #after="{ spec }"><p class="after-probe">{{ spec.key }}</p></template>`,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
expect(wrapper.findAll('.after-probe').map((p) => p.text())).toEqual([
|
|
72
|
+
'mode',
|
|
73
|
+
'enabled',
|
|
74
|
+
'threshold',
|
|
75
|
+
'lookback',
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BaseInput from './BaseInput.vue'
|
|
3
|
+
import BaseSelect from './BaseSelect.vue'
|
|
4
|
+
import type { SpecField, SpecFieldValue } from '../types/specField'
|
|
5
|
+
|
|
6
|
+
// Spec-driven form fields: renders a select / checkbox / number input per
|
|
7
|
+
// field spec, with labels and help text, in a responsive two-column grid.
|
|
8
|
+
// Feed it a backend-described catalogue (or any SpecField[]) and every form
|
|
9
|
+
// that edits those values stays in lockstep.
|
|
10
|
+
//
|
|
11
|
+
// The component never mutates `params`: every edit is emitted as
|
|
12
|
+
// (key, value) and the owner writes it back into its own form state.
|
|
13
|
+
defineProps<{
|
|
14
|
+
specs: SpecField[]
|
|
15
|
+
params: Record<string, SpecFieldValue>
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
(e: 'update', key: string, value: SpecFieldValue): void
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
// Per-spec extra content rendered under a field (e.g. a live preview a
|
|
23
|
+
// caller attaches to one specific key).
|
|
24
|
+
defineSlots<{
|
|
25
|
+
after?: (props: { spec: SpecField }) => unknown
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
function onCheckbox(key: string, e: Event) {
|
|
29
|
+
emit('update', key, (e.target as HTMLInputElement).checked)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mirror v-model.number: a parseable value becomes a number, anything else
|
|
33
|
+
// (including a cleared field, which becomes '') passes through as the raw
|
|
34
|
+
// string so the owner's "enter a valid number" validation can catch it.
|
|
35
|
+
function onNumber(key: string, raw: string) {
|
|
36
|
+
const n = parseFloat(raw)
|
|
37
|
+
emit('update', key, Number.isNaN(n) ? raw : n)
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
43
|
+
<div v-for="spec in specs" :key="spec.key">
|
|
44
|
+
<label
|
|
45
|
+
v-if="spec.type !== 'boolean'"
|
|
46
|
+
class="block text-[11px] uppercase tracking-wide text-slate-500 mb-1"
|
|
47
|
+
>
|
|
48
|
+
{{ spec.label }}
|
|
49
|
+
</label>
|
|
50
|
+
|
|
51
|
+
<BaseSelect
|
|
52
|
+
v-if="spec.type === 'select'"
|
|
53
|
+
:modelValue="String(params[spec.key] ?? '')"
|
|
54
|
+
@update:modelValue="(v: string) => emit('update', spec.key, v)"
|
|
55
|
+
>
|
|
56
|
+
<option v-for="opt in spec.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
|
|
57
|
+
</BaseSelect>
|
|
58
|
+
|
|
59
|
+
<label v-else-if="spec.type === 'boolean'" class="flex items-center gap-2 mt-4">
|
|
60
|
+
<input
|
|
61
|
+
:checked="Boolean(params[spec.key])"
|
|
62
|
+
type="checkbox"
|
|
63
|
+
class="h-4 w-4"
|
|
64
|
+
@change="onCheckbox(spec.key, $event)"
|
|
65
|
+
/>
|
|
66
|
+
<span class="text-sm text-slate-900 dark:text-slate-100">{{ spec.label }}</span>
|
|
67
|
+
</label>
|
|
68
|
+
|
|
69
|
+
<BaseInput
|
|
70
|
+
v-else
|
|
71
|
+
:modelValue="params[spec.key] as string | number"
|
|
72
|
+
type="number"
|
|
73
|
+
:step="spec.step ?? (spec.type === 'integer' ? 1 : 0.01)"
|
|
74
|
+
:min="spec.min ?? undefined"
|
|
75
|
+
@update:modelValue="(v: string) => onNumber(spec.key, v)"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<p v-if="spec.help" class="text-[11px] text-slate-400 mt-1">{{ spec.help }}</p>
|
|
79
|
+
|
|
80
|
+
<slot name="after" :spec="spec" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseTable from './BaseTable.vue'
|
|
4
|
+
import type { TableColumn } from '../types/table'
|
|
5
|
+
|
|
6
|
+
const columns: TableColumn[] = [
|
|
7
|
+
{ label: 'Symbol' },
|
|
8
|
+
{ label: 'Qty', align: 'right' },
|
|
9
|
+
{ label: 'Bought' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseTable', () => {
|
|
13
|
+
it('renders header cells with right-alignment where requested', () => {
|
|
14
|
+
const wrapper = mount(BaseTable, { props: { columns } })
|
|
15
|
+
const ths = wrapper.findAll('th')
|
|
16
|
+
expect(ths.map((th) => th.text())).toEqual(['Symbol', 'Qty', 'Bought'])
|
|
17
|
+
expect(ths[1].classes()).toContain('text-right')
|
|
18
|
+
expect(ths[0].classes()).not.toContain('text-right')
|
|
19
|
+
expect(ths[0].classes()).toEqual(expect.arrayContaining(['px-4', 'py-3']))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders caller rows through the default slot', () => {
|
|
23
|
+
const wrapper = mount(BaseTable, {
|
|
24
|
+
props: { columns },
|
|
25
|
+
slots: { default: '<tr><td>AMD</td><td>3</td><td>today</td></tr>' },
|
|
26
|
+
})
|
|
27
|
+
expect(wrapper.find('tbody td').text()).toBe('AMD')
|
|
28
|
+
expect(wrapper.find('table').classes()).toEqual(expect.arrayContaining(['w-full', 'text-sm']))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders the empty-state row spanning every column when empty', () => {
|
|
32
|
+
const wrapper = mount(BaseTable, {
|
|
33
|
+
props: { columns, empty: true, emptyText: 'Nothing here.' },
|
|
34
|
+
})
|
|
35
|
+
const td = wrapper.find('tbody td')
|
|
36
|
+
expect(td.attributes('colspan')).toBe('3')
|
|
37
|
+
expect(td.text()).toBe('Nothing here.')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('prefers the empty slot over emptyText', () => {
|
|
41
|
+
const wrapper = mount(BaseTable, {
|
|
42
|
+
props: { columns, empty: true, emptyText: 'fallback' },
|
|
43
|
+
slots: { empty: 'No trades match your filters.' },
|
|
44
|
+
})
|
|
45
|
+
expect(wrapper.find('tbody td').text()).toBe('No trades match your filters.')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders no empty-state row when not empty', () => {
|
|
49
|
+
const wrapper = mount(BaseTable, {
|
|
50
|
+
props: { columns, empty: false },
|
|
51
|
+
slots: { default: '<tr><td>row</td></tr>' },
|
|
52
|
+
})
|
|
53
|
+
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Styling shell for data tables — NOT a data grid. Owns the table skin
|
|
4
|
+
(slate header band, px-4 py-3 header cells, empty-state row); rows are
|
|
5
|
+
the caller's own <tr> markup via the default slot, so cell content and
|
|
6
|
+
per-cell styling stay fully in the caller's hands.
|
|
7
|
+
|
|
8
|
+
Wrap it yourself for scrolling/card chrome (e.g. inside a BaseRow with
|
|
9
|
+
overflow-x-auto). Same `dark:`-variant note as BaseInput.
|
|
10
|
+
-->
|
|
11
|
+
<table class="w-full text-sm">
|
|
12
|
+
<thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
|
|
13
|
+
<tr class="text-left">
|
|
14
|
+
<th
|
|
15
|
+
v-for="(col, i) in columns"
|
|
16
|
+
:key="i"
|
|
17
|
+
class="px-4 py-3"
|
|
18
|
+
:class="col.align === 'right' ? 'text-right' : ''"
|
|
19
|
+
>
|
|
20
|
+
{{ col.label }}
|
|
21
|
+
</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<tr v-if="empty">
|
|
26
|
+
<td :colspan="columns.length" class="px-4 py-12 text-center text-slate-500">
|
|
27
|
+
<slot name="empty">{{ emptyText }}</slot>
|
|
28
|
+
</td>
|
|
29
|
+
</tr>
|
|
30
|
+
<slot />
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import type { TableColumn } from '../types/table'
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
columns: TableColumn[]
|
|
40
|
+
/** True when there are no rows — renders the empty-state row. */
|
|
41
|
+
empty?: boolean
|
|
42
|
+
/** Fallback empty-state text; the `empty` slot overrides it. */
|
|
43
|
+
emptyText?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
withDefaults(defineProps<Props>(), {
|
|
47
|
+
empty: false,
|
|
48
|
+
emptyText: 'No rows.',
|
|
49
|
+
})
|
|
50
|
+
</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
|
@@ -34,6 +34,13 @@ export { default as BaseGoogleSignInButton } from './components/BaseGoogleSignIn
|
|
|
34
34
|
export { default as BaseLoginForm } from './components/BaseLoginForm.vue'
|
|
35
35
|
export { default as BaseNotificationPanel } from './components/BaseNotificationPanel.vue'
|
|
36
36
|
|
|
37
|
+
// Components — extracted from TradeAutomation
|
|
38
|
+
export { default as BaseInput } from './components/BaseInput.vue'
|
|
39
|
+
export { default as BaseSelect } from './components/BaseSelect.vue'
|
|
40
|
+
export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
|
|
41
|
+
export { default as BaseTable } from './components/BaseTable.vue'
|
|
42
|
+
export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
|
|
43
|
+
|
|
37
44
|
// Composables
|
|
38
45
|
export { useTheme, initTheme } from './composables/useTheme'
|
|
39
46
|
export type { UseThemeOptions } from './composables/useTheme'
|
|
@@ -65,6 +72,9 @@ export type { NavItem, NavSection } from './types/sidebar'
|
|
|
65
72
|
export type { EntityPickerItem } from './types/entityPicker'
|
|
66
73
|
export type { LoginCredentials } from './types/auth'
|
|
67
74
|
export type { NotificationItem } from './types/notification'
|
|
75
|
+
export type { SegmentedOption } from './types/segmented'
|
|
76
|
+
export type { TableColumn } from './types/table'
|
|
77
|
+
export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
|
|
68
78
|
|
|
69
79
|
// Utils
|
|
70
80
|
export { getBaseColor, getBaseColorOf } from './utils/util'
|