mgv-backoffice 1.34.0 → 1.36.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 +81 -8
- package/dist/components/BaseBarDistribution.vue.d.ts +24 -0
- package/dist/components/BasePageHeader.vue.d.ts +8 -2
- package/dist/components/BasePillPickerModal.vue.d.ts +43 -0
- package/dist/components/BaseToolbarButton.vue.d.ts +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/types/distribution.d.ts +7 -0
- package/dist/types/pillPicker.d.ts +14 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +702 -558
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseBarDistribution.test.ts +51 -0
- package/src/components/BaseBarDistribution.vue +73 -0
- package/src/components/BasePageHeader.vue +24 -7
- package/src/components/BasePillPickerModal.test.ts +95 -0
- package/src/components/BasePillPickerModal.vue +0 -0
- package/src/components/BaseToolbarButton.test.ts +48 -0
- package/src/components/BaseToolbarButton.vue +21 -6
- package/src/composables/usePolling.ts +15 -3
- package/src/index.ts +4 -0
- package/src/types/distribution.ts +7 -0
- package/src/types/pillPicker.ts +14 -0
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseBarDistribution from './BaseBarDistribution.vue'
|
|
4
|
+
import type { DistributionBar } from '../types/distribution'
|
|
5
|
+
|
|
6
|
+
const BARS: DistributionBar[] = [
|
|
7
|
+
{ label: '0.5', count: 1 },
|
|
8
|
+
{ label: '1', count: 4 },
|
|
9
|
+
{ label: '2', count: 2 },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseBarDistribution', () => {
|
|
13
|
+
it('renders one bar per entry with its count and label', () => {
|
|
14
|
+
const wrap = mount(BaseBarDistribution, { props: { bars: BARS } })
|
|
15
|
+
const bars = wrap.findAll('div[title]')
|
|
16
|
+
expect(bars).toHaveLength(3)
|
|
17
|
+
expect(wrap.text()).toContain('4')
|
|
18
|
+
expect(wrap.text()).toContain('0.5')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('scales bar heights against the tallest count, with a 3px floor', () => {
|
|
22
|
+
const wrap = mount(BaseBarDistribution, {
|
|
23
|
+
props: { bars: [...BARS, { label: '9', count: 0 }], maxBarHeight: 56 },
|
|
24
|
+
})
|
|
25
|
+
const heights = wrap
|
|
26
|
+
.findAll('.rounded-t')
|
|
27
|
+
.map((el) => el.attributes('style'))
|
|
28
|
+
// count 4 → full height; count 2 → half; count 0 → 3px floor.
|
|
29
|
+
expect(heights[1]).toContain('height: 56px')
|
|
30
|
+
expect(heights[2]).toContain('height: 28px')
|
|
31
|
+
expect(heights[3]).toContain('height: 3px')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('builds singular/plural tooltips with the prefix and noun', () => {
|
|
35
|
+
const wrap = mount(BaseBarDistribution, {
|
|
36
|
+
props: { bars: BARS, titlePrefix: 'Take profit', countNoun: 'variant' },
|
|
37
|
+
})
|
|
38
|
+
const titles = wrap
|
|
39
|
+
.findAll('div[title]')
|
|
40
|
+
.map((el) => el.attributes('title'))
|
|
41
|
+
expect(titles[0]).toBe('Take profit 0.5: 1 variant')
|
|
42
|
+
expect(titles[1]).toBe('Take profit 1: 4 variants')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('exposes the aria label on the chart container', () => {
|
|
46
|
+
const wrap = mount(BaseBarDistribution, {
|
|
47
|
+
props: { bars: BARS, ariaLabel: 'Distribution of X' },
|
|
48
|
+
})
|
|
49
|
+
expect(wrap.get('[role="img"]').attributes('aria-label')).toBe('Distribution of X')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Compact value-distribution chart: one thin rounded bar per distinct value,
|
|
3
|
+
// count labelled on top and the value underneath — scrolls sideways when
|
|
4
|
+
// there are many bars. Pure Tailwind, no chart library. Extracted from
|
|
5
|
+
// TradeAutomation's variant-stats modal, where it summarises how often each
|
|
6
|
+
// parameter value occurs across generated strategy variants.
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import type { DistributionBar } from '../types/distribution'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
/** Bars in display order (callers sort ascending for numeric values). */
|
|
13
|
+
bars: DistributionBar[]
|
|
14
|
+
/** Accessible description of what the chart shows. */
|
|
15
|
+
ariaLabel?: string
|
|
16
|
+
/** Noun for each bar's tooltip count, e.g. "variant" → "3 variants". */
|
|
17
|
+
countNoun?: string
|
|
18
|
+
/** Tooltip prefix before the value, e.g. the field name. */
|
|
19
|
+
titlePrefix?: string
|
|
20
|
+
/** Height of the tallest bar, in px. */
|
|
21
|
+
maxBarHeight?: number
|
|
22
|
+
/** Tailwind classes for the bar fill. */
|
|
23
|
+
barClass?: string
|
|
24
|
+
}>(),
|
|
25
|
+
{
|
|
26
|
+
ariaLabel: 'Value distribution',
|
|
27
|
+
countNoun: 'item',
|
|
28
|
+
titlePrefix: '',
|
|
29
|
+
maxBarHeight: 56,
|
|
30
|
+
barClass: 'bg-emerald-500/80 dark:bg-emerald-400/80',
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const maxCount = computed(() => Math.max(1, ...props.bars.map((b) => b.count)))
|
|
35
|
+
|
|
36
|
+
const barHeight = (b: DistributionBar): number =>
|
|
37
|
+
Math.max(3, Math.round((b.count / maxCount.value) * props.maxBarHeight))
|
|
38
|
+
|
|
39
|
+
const barTitle = (b: DistributionBar): string =>
|
|
40
|
+
`${props.titlePrefix ? `${props.titlePrefix} ` : ''}${b.label}: ${b.count} ${props.countNoun}${
|
|
41
|
+
b.count === 1 ? '' : 's'
|
|
42
|
+
}`
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div
|
|
47
|
+
class="flex items-end gap-1.5 overflow-x-auto pb-1"
|
|
48
|
+
role="img"
|
|
49
|
+
:aria-label="ariaLabel"
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
v-for="b in bars"
|
|
53
|
+
:key="b.label"
|
|
54
|
+
class="flex min-w-7 flex-1 flex-col items-center gap-0.5"
|
|
55
|
+
:title="barTitle(b)"
|
|
56
|
+
>
|
|
57
|
+
<span class="text-[10px] tabular-nums text-slate-400 dark:text-slate-500">
|
|
58
|
+
{{ b.count }}
|
|
59
|
+
</span>
|
|
60
|
+
<div
|
|
61
|
+
class="w-full max-w-8 rounded-t"
|
|
62
|
+
:class="barClass"
|
|
63
|
+
:style="{ height: `${barHeight(b)}px` }"
|
|
64
|
+
/>
|
|
65
|
+
<span
|
|
66
|
+
class="max-w-full truncate font-mono text-[10px] text-slate-500 dark:text-slate-400"
|
|
67
|
+
:title="b.label"
|
|
68
|
+
>
|
|
69
|
+
{{ b.label }}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
Slots:
|
|
7
7
|
• icon — the page-specific Heroicon. Receives an `iconClass`
|
|
8
8
|
slot prop carrying the theme-aware text colour, so callers can
|
|
9
|
-
bind `:class="iconClass"` on their icon component.
|
|
9
|
+
bind `:class="iconClass"` on their icon component. The badge
|
|
10
|
+
square only renders when this slot is filled, so icon-less apps
|
|
11
|
+
get a plain title/subtitle header.
|
|
12
|
+
• subtitle — rich subtitle content (links, emphasis); overrides
|
|
13
|
+
the `subtitle` prop.
|
|
10
14
|
• actions — refresh / destructive buttons rendered on the right.
|
|
11
15
|
|
|
12
16
|
Props:
|
|
@@ -16,18 +20,27 @@
|
|
|
16
20
|
('emerald' | 'sky' | 'red' | 'amber'). Defaults to 'emerald'.
|
|
17
21
|
• maxWidthClass — Tailwind max-w utility constraining the header
|
|
18
22
|
width so sibling views can share a footprint. Defaults to
|
|
19
|
-
'max-w-4xl'
|
|
23
|
+
'max-w-4xl'; pass '' to skip the width wrapper entirely (the
|
|
24
|
+
header then spans whatever container it sits in).
|
|
25
|
+
• align — vertical alignment of the title block vs the actions:
|
|
26
|
+
'center' (default) or 'end' (actions sit on the title baseline).
|
|
27
|
+
• marginClass — space under the header. Defaults to 'mb-8'; pass
|
|
28
|
+
'' when the parent already manages vertical rhythm (space-y-*).
|
|
20
29
|
-->
|
|
21
|
-
<
|
|
30
|
+
<header :class="maxWidthClass ? ['mx-auto px-4 sm:px-6', maxWidthClass] : undefined">
|
|
22
31
|
<!--
|
|
23
32
|
flex-wrap + gap so a long title and the action buttons reflow onto
|
|
24
33
|
separate rows on narrow (mobile) viewports instead of the actions
|
|
25
34
|
overflowing off the right edge of the screen. min-w-0 on the title
|
|
26
35
|
group lets a long title truncate rather than shoving the actions out.
|
|
27
36
|
-->
|
|
28
|
-
<div
|
|
37
|
+
<div
|
|
38
|
+
class="flex flex-wrap justify-between gap-3"
|
|
39
|
+
:class="[align === 'end' ? 'items-end' : 'items-center', marginClass]"
|
|
40
|
+
>
|
|
29
41
|
<div class="flex items-center gap-3 min-w-0">
|
|
30
42
|
<div
|
|
43
|
+
v-if="$slots.icon"
|
|
31
44
|
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
|
32
45
|
:class="badgeBgClass"
|
|
33
46
|
>
|
|
@@ -37,8 +50,8 @@
|
|
|
37
50
|
<h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
|
|
38
51
|
{{ title }}
|
|
39
52
|
</h1>
|
|
40
|
-
<p v-if="subtitle" class="text-sm" :class="t.dimTextAlt">
|
|
41
|
-
{{ subtitle }}
|
|
53
|
+
<p v-if="subtitle || $slots.subtitle" class="text-sm" :class="t.dimTextAlt">
|
|
54
|
+
<slot name="subtitle">{{ subtitle }}</slot>
|
|
42
55
|
</p>
|
|
43
56
|
</div>
|
|
44
57
|
</div>
|
|
@@ -46,7 +59,7 @@
|
|
|
46
59
|
<slot name="actions" />
|
|
47
60
|
</div>
|
|
48
61
|
</div>
|
|
49
|
-
</
|
|
62
|
+
</header>
|
|
50
63
|
</template>
|
|
51
64
|
|
|
52
65
|
<script setup lang="ts">
|
|
@@ -62,11 +75,15 @@ const props = withDefaults(
|
|
|
62
75
|
subtitle?: string
|
|
63
76
|
iconColor?: IconColor
|
|
64
77
|
maxWidthClass?: string
|
|
78
|
+
align?: 'center' | 'end'
|
|
79
|
+
marginClass?: string
|
|
65
80
|
}>(),
|
|
66
81
|
{
|
|
67
82
|
subtitle: '',
|
|
68
83
|
iconColor: 'emerald',
|
|
69
84
|
maxWidthClass: 'max-w-4xl',
|
|
85
|
+
align: 'center',
|
|
86
|
+
marginClass: 'mb-8',
|
|
70
87
|
},
|
|
71
88
|
)
|
|
72
89
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BasePillPickerModal from './BasePillPickerModal.vue'
|
|
4
|
+
import type { PillPickerItem } from '../types/pillPicker'
|
|
5
|
+
|
|
6
|
+
const items: PillPickerItem[] = [
|
|
7
|
+
{ id: 'STOCK:AAPL', label: 'AAPL', group: 'STOCK' },
|
|
8
|
+
{ id: 'STOCK:MSFT', label: 'MSFT', group: 'STOCK', title: 'Microsoft' },
|
|
9
|
+
{ id: 'CRYPTO:BTC/USD', label: 'BTC/USD', group: 'CRYPTO' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const groups = [
|
|
13
|
+
{ value: 'STOCK', label: 'Stock' },
|
|
14
|
+
{ value: 'CRYPTO', label: 'Crypto' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
function mountPicker(props: Record<string, unknown> = {}) {
|
|
18
|
+
return mount(BasePillPickerModal, {
|
|
19
|
+
props: { title: 'Pick one', items, ...props },
|
|
20
|
+
global: { stubs: { teleport: true } },
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pills are the buttons inside the flex-wrap grid — not the segmented
|
|
25
|
+
// control's buttons and not the footer Close.
|
|
26
|
+
function pillLabels(wrapper: ReturnType<typeof mountPicker>): string[] {
|
|
27
|
+
return wrapper
|
|
28
|
+
.findAll('.flex.flex-wrap.gap-2 > button')
|
|
29
|
+
.map((b) => b.text())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('BasePillPickerModal', () => {
|
|
33
|
+
it('renders one pill per item and emits pick with the clicked item', async () => {
|
|
34
|
+
const wrapper = mountPicker()
|
|
35
|
+
expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
|
|
36
|
+
await wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].trigger('click')
|
|
37
|
+
expect(wrapper.emitted('pick')).toEqual([[items[1]]])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('narrows by free text, case-insensitively, and shows the no-match message', async () => {
|
|
41
|
+
const wrapper = mountPicker({ noMatchMessage: 'nothing here' })
|
|
42
|
+
await wrapper.find('input').setValue('btc')
|
|
43
|
+
expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
|
|
44
|
+
await wrapper.find('input').setValue('zzz')
|
|
45
|
+
expect(wrapper.text()).toContain('nothing here')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders the group toggle with All prepended and narrows by group', async () => {
|
|
49
|
+
const wrapper = mountPicker({ groups })
|
|
50
|
+
// Re-query before each click — the grid re-render replaces the buttons,
|
|
51
|
+
// so a wrapper captured before a click goes stale.
|
|
52
|
+
const seg = () => wrapper.findAll('[role="group"] button')
|
|
53
|
+
expect(seg().map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
|
|
54
|
+
await seg()[2].trigger('click')
|
|
55
|
+
expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
|
|
56
|
+
await seg()[0].trigger('click')
|
|
57
|
+
expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('hides the group toggle when no groups are passed', () => {
|
|
61
|
+
const wrapper = mountPicker()
|
|
62
|
+
expect(wrapper.find('[role="group"]').exists()).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows the empty message instead of the filter row when there are no items', () => {
|
|
66
|
+
const wrapper = mountPicker({ items: [], emptyMessage: 'nothing configured' })
|
|
67
|
+
expect(wrapper.text()).toContain('nothing configured')
|
|
68
|
+
expect(wrapper.find('input').exists()).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('applies the mono font to the input and pills only when asked', () => {
|
|
72
|
+
const mono = mountPicker({ mono: true })
|
|
73
|
+
expect(mono.find('input').classes()).toContain('font-mono')
|
|
74
|
+
expect(mono.findAll('.flex.flex-wrap.gap-2 > button')[0].classes()).toContain('font-mono')
|
|
75
|
+
|
|
76
|
+
const plain = mountPicker()
|
|
77
|
+
expect(plain.find('input').classes()).not.toContain('font-mono')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('forwards an item title to the pill tooltip', () => {
|
|
81
|
+
const wrapper = mountPicker()
|
|
82
|
+
expect(
|
|
83
|
+
wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].attributes('title'),
|
|
84
|
+
).toBe('Microsoft')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('emits close from the footer button', async () => {
|
|
88
|
+
const wrapper = mountPicker({ closeText: 'Dismiss' })
|
|
89
|
+
const buttons = wrapper.findAll('button')
|
|
90
|
+
const closeBtn = buttons[buttons.length - 1]
|
|
91
|
+
expect(closeBtn.text()).toBe('Dismiss')
|
|
92
|
+
await closeBtn.trigger('click')
|
|
93
|
+
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseToolbarButton from './BaseToolbarButton.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseToolbarButton', () => {
|
|
6
|
+
it('renders the neutral pill shape by default', () => {
|
|
7
|
+
const wrapper = mount(BaseToolbarButton, { props: { label: 'Refresh' } })
|
|
8
|
+
const cls = wrapper.find('button').classes()
|
|
9
|
+
expect(cls).toContain('rounded-lg')
|
|
10
|
+
expect(cls).toContain('py-2')
|
|
11
|
+
expect(cls).toContain('font-medium')
|
|
12
|
+
expect(cls).toContain('disabled:opacity-50')
|
|
13
|
+
expect(wrapper.text()).toBe('Refresh')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('keeps the danger variant on the pill shape with the red palette', () => {
|
|
17
|
+
const wrapper = mount(BaseToolbarButton, {
|
|
18
|
+
props: { label: 'Delete', variant: 'danger' },
|
|
19
|
+
})
|
|
20
|
+
const cls = wrapper.find('button').classes()
|
|
21
|
+
expect(cls).toContain('rounded-lg')
|
|
22
|
+
expect(cls).toContain('bg-red-600')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders the ghost variant as the slate h-9 rounded-md outline button', () => {
|
|
26
|
+
const wrapper = mount(BaseToolbarButton, {
|
|
27
|
+
props: { label: 'Close', variant: 'ghost' },
|
|
28
|
+
})
|
|
29
|
+
const cls = wrapper.find('button').classes()
|
|
30
|
+
expect(cls).toContain('h-9')
|
|
31
|
+
expect(cls).toContain('rounded-md')
|
|
32
|
+
expect(cls).toContain('px-4')
|
|
33
|
+
expect(cls).toContain('font-semibold')
|
|
34
|
+
expect(cls).toContain('border-slate-300')
|
|
35
|
+
expect(cls).toContain('disabled:opacity-40')
|
|
36
|
+
expect(cls).not.toContain('rounded-lg')
|
|
37
|
+
expect(cls).not.toContain('py-2')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('emits click and honours disabled', async () => {
|
|
41
|
+
const wrapper = mount(BaseToolbarButton, { props: { label: 'Go' } })
|
|
42
|
+
await wrapper.find('button').trigger('click')
|
|
43
|
+
expect(wrapper.emitted('click')).toHaveLength(1)
|
|
44
|
+
|
|
45
|
+
const disabled = mount(BaseToolbarButton, { props: { label: 'Go', disabled: true } })
|
|
46
|
+
expect(disabled.find('button').attributes('disabled')).toBeDefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
Props:
|
|
12
12
|
• label — button text (rendered in a <span>). Optional so an
|
|
13
13
|
icon-only toolbar button is possible.
|
|
14
|
-
• variant — 'neutral' (default grey)
|
|
14
|
+
• variant — 'neutral' (default grey), 'danger' (solid red) or
|
|
15
|
+
'ghost' (slate outline — TradeAutomation's toolbar/footer idiom).
|
|
15
16
|
• disabled — greys out + blocks the click.
|
|
16
17
|
• title — native tooltip / accessibility text.
|
|
17
18
|
• type — native button type. Defaults to 'button' so the
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
:type="type"
|
|
22
23
|
:disabled="disabled"
|
|
23
24
|
:title="title"
|
|
24
|
-
class="inline-flex shrink-0 items-center gap-2
|
|
25
|
+
class="inline-flex shrink-0 items-center gap-2 text-sm border transition-colors cursor-pointer disabled:cursor-not-allowed whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
25
26
|
:class="variantClass"
|
|
26
27
|
@click="$emit('click', $event)"
|
|
27
28
|
>
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
import { computed } from 'vue'
|
|
35
36
|
import { useTheme } from '../composables/useTheme'
|
|
36
37
|
|
|
37
|
-
type ToolbarVariant = 'neutral' | 'danger'
|
|
38
|
+
type ToolbarVariant = 'neutral' | 'danger' | 'ghost'
|
|
38
39
|
|
|
39
40
|
const props = withDefaults(
|
|
40
41
|
defineProps<{
|
|
@@ -59,9 +60,19 @@ defineEmits<{
|
|
|
59
60
|
|
|
60
61
|
const { isDark } = useTheme()
|
|
61
62
|
|
|
62
|
-
// Resolves the variant + theme into
|
|
63
|
+
// Resolves the variant + theme into shape and colour classes. The danger
|
|
63
64
|
// style is normalised here so every destructive toolbar button reads
|
|
64
|
-
// identically (red-500 hover in dark, red-700 hover in light).
|
|
65
|
+
// identically (red-500 hover in dark, red-700 hover in light). Shape lives
|
|
66
|
+
// per-variant because 'ghost' — the slate outline button extracted from
|
|
67
|
+
// TradeAutomation's toolbars and modal footers — is a fixed-height
|
|
68
|
+
// rounded-md button, not the neutral/danger pill; it themes via dark:
|
|
69
|
+
// utilities so it renders the same markup in both themes.
|
|
70
|
+
const SHAPE: Record<ToolbarVariant, string> = {
|
|
71
|
+
neutral: 'px-3 py-2 rounded-lg font-medium disabled:opacity-50',
|
|
72
|
+
danger: 'px-3 py-2 rounded-lg font-medium disabled:opacity-50',
|
|
73
|
+
ghost: 'h-9 px-4 rounded-md font-semibold disabled:opacity-40 disabled:hover:bg-slate-50 dark:disabled:hover:bg-slate-900',
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
const variantClass = computed(() => {
|
|
66
77
|
const palette: Record<ToolbarVariant, { dark: string; light: string }> = {
|
|
67
78
|
neutral: {
|
|
@@ -72,8 +83,12 @@ const variantClass = computed(() => {
|
|
|
72
83
|
dark: 'bg-red-600 text-white border-red-600 hover:bg-red-500',
|
|
73
84
|
light: 'bg-red-600 text-white border-red-600 hover:bg-red-700',
|
|
74
85
|
},
|
|
86
|
+
ghost: {
|
|
87
|
+
dark: 'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
|
|
88
|
+
light: 'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
|
|
89
|
+
},
|
|
75
90
|
}
|
|
76
91
|
const entry = palette[props.variant]
|
|
77
|
-
return isDark.value ? entry.dark : entry.light
|
|
92
|
+
return [SHAPE[props.variant], isDark.value ? entry.dark : entry.light]
|
|
78
93
|
})
|
|
79
94
|
</script>
|
|
@@ -35,6 +35,10 @@ export function usePolling(
|
|
|
35
35
|
|
|
36
36
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
37
37
|
const active = ref(false)
|
|
38
|
+
// True only when the visibility handler paused an ACTIVE loop. A loop the
|
|
39
|
+
// caller stopped explicitly (stop(), e.g. an auto-refresh toggle) must stay
|
|
40
|
+
// stopped across hide/show cycles instead of being silently restarted.
|
|
41
|
+
let suspendedByVisibility = false
|
|
38
42
|
|
|
39
43
|
function startTimer() {
|
|
40
44
|
if (timer !== null || intervalMs === null) return
|
|
@@ -49,6 +53,7 @@ export function usePolling(
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
function start() {
|
|
56
|
+
suspendedByVisibility = false
|
|
52
57
|
if (active.value) return
|
|
53
58
|
active.value = true
|
|
54
59
|
if (immediate) void fn()
|
|
@@ -56,19 +61,26 @@ export function usePolling(
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
function stop() {
|
|
64
|
+
suspendedByVisibility = false
|
|
59
65
|
active.value = false
|
|
60
66
|
stopTimer()
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
function onVisibilityChange() {
|
|
64
70
|
if (document.visibilityState === 'visible') {
|
|
65
|
-
//
|
|
66
|
-
//
|
|
71
|
+
// Only resume a loop THIS handler paused — a caller-stopped loop
|
|
72
|
+
// stays stopped. Refresh right away so stale data doesn't linger
|
|
73
|
+
// for a full interval after the user returns, then re-arm the timer.
|
|
74
|
+
if (!suspendedByVisibility) return
|
|
75
|
+
suspendedByVisibility = false
|
|
67
76
|
active.value = true
|
|
68
77
|
void fn()
|
|
69
78
|
startTimer()
|
|
70
79
|
} else {
|
|
71
|
-
|
|
80
|
+
if (!active.value) return
|
|
81
|
+
active.value = false
|
|
82
|
+
stopTimer()
|
|
83
|
+
suspendedByVisibility = true
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,8 @@ export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
|
|
|
49
49
|
export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
|
|
50
50
|
export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
|
|
51
51
|
export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
|
|
52
|
+
export { default as BasePillPickerModal } from './components/BasePillPickerModal.vue'
|
|
53
|
+
export { default as BaseBarDistribution } from './components/BaseBarDistribution.vue'
|
|
52
54
|
export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
|
|
53
55
|
|
|
54
56
|
// Composables
|
|
@@ -92,6 +94,8 @@ export type { DropdownOption } from './types/dropdown'
|
|
|
92
94
|
export type { TableColumn } from './types/table'
|
|
93
95
|
export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
|
|
94
96
|
export type { StatBreakdownItem } from './types/statBreakdown'
|
|
97
|
+
export type { PillPickerItem } from './types/pillPicker'
|
|
98
|
+
export type { DistributionBar } from './types/distribution'
|
|
95
99
|
|
|
96
100
|
// Utils
|
|
97
101
|
export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One clickable pill in a BasePillPickerModal.
|
|
3
|
+
*
|
|
4
|
+
* `id` must be unique (it keys the pill and identifies the pick),
|
|
5
|
+
* `label` is what the pill shows and what the free-text filter matches,
|
|
6
|
+
* `group` assigns the pill to one of the modal's segmented-filter groups,
|
|
7
|
+
* `title` becomes the pill's tooltip.
|
|
8
|
+
*/
|
|
9
|
+
export interface PillPickerItem {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
group?: string
|
|
13
|
+
title?: string
|
|
14
|
+
}
|