mgv-backoffice 1.36.0 → 1.36.2

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.
Files changed (60) hide show
  1. package/README.md +1794 -1719
  2. package/dist/components/BaseBadge.vue.d.ts +1 -11
  3. package/dist/components/BaseDropdown.vue.d.ts +1 -1
  4. package/dist/ui-lib.js +942 -925
  5. package/dist/ui-lib.umd.cjs +1 -1
  6. package/dist/utils/specForm.d.ts +1 -1
  7. package/package.json +54 -54
  8. package/src/components/BaseAlert.vue +104 -104
  9. package/src/components/BaseBadge.vue +12 -2
  10. package/src/components/BaseBarDistribution.test.ts +51 -51
  11. package/src/components/BaseBarDistribution.vue +73 -73
  12. package/src/components/BaseChipButton.vue +42 -42
  13. package/src/components/BaseCodeBlock.test.ts +70 -70
  14. package/src/components/BaseCodeBlock.vue +74 -74
  15. package/src/components/BaseCollapsibleSection.vue +113 -113
  16. package/src/components/BaseConfirmModal.vue +114 -114
  17. package/src/components/BaseCredentialsForm.test.ts +108 -108
  18. package/src/components/BaseCredentialsForm.vue +241 -241
  19. package/src/components/BaseDropdown.test.ts +87 -87
  20. package/src/components/BaseDropdown.vue +172 -172
  21. package/src/components/BaseEntityPickerModal.vue +330 -330
  22. package/src/components/BaseFileDropzone.vue +94 -94
  23. package/src/components/BaseFilterChip.test.ts +44 -44
  24. package/src/components/BaseFilterChip.vue +67 -67
  25. package/src/components/BaseModalShell.vue +209 -209
  26. package/src/components/BasePageHeader.vue +120 -120
  27. package/src/components/BasePillPickerModal.test.ts +95 -95
  28. package/src/components/BaseRemoveButton.vue +22 -22
  29. package/src/components/BaseSpecFields.test.ts +39 -0
  30. package/src/components/BaseSpecFields.vue +27 -3
  31. package/src/components/BaseStatBreakdown.test.ts +56 -56
  32. package/src/components/BaseStatBreakdown.vue +49 -49
  33. package/src/components/BaseStatusPill.vue +54 -54
  34. package/src/components/BaseTextInputModal.vue +140 -140
  35. package/src/components/BaseToolbarButton.test.ts +48 -48
  36. package/src/components/BaseToolbarButton.vue +94 -94
  37. package/src/composables/useFieldClasses.test.ts +55 -55
  38. package/src/composables/useFieldClasses.ts +56 -56
  39. package/src/composables/usePolling.test.ts +112 -112
  40. package/src/composables/usePolling.ts +102 -102
  41. package/src/composables/useQueryParamSync.test.ts +56 -56
  42. package/src/composables/useQueryParamSync.ts +42 -42
  43. package/src/composables/useThemeClasses.test.ts +34 -34
  44. package/src/composables/useThemeClasses.ts +137 -137
  45. package/src/index.ts +138 -138
  46. package/src/style.css +32 -32
  47. package/src/types/distribution.ts +7 -7
  48. package/src/types/pillPicker.ts +14 -14
  49. package/src/types/specField.ts +30 -30
  50. package/src/types/statBreakdown.ts +6 -6
  51. package/src/utils/format.test.ts +264 -264
  52. package/src/utils/format.ts +216 -216
  53. package/src/utils/httpColors.test.ts +100 -100
  54. package/src/utils/httpColors.ts +99 -99
  55. package/src/utils/kvRows.test.ts +47 -47
  56. package/src/utils/kvRows.ts +34 -34
  57. package/src/utils/specForm.test.ts +64 -53
  58. package/src/utils/specForm.ts +49 -48
  59. package/src/utils/validate.test.ts +82 -82
  60. package/src/utils/validate.ts +76 -76
@@ -1,95 +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
- })
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
+ })
@@ -1,22 +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
- &times;
17
- </button>
18
- </template>
19
-
20
- <script lang="ts" setup>
21
- const emit = defineEmits<{ click: [] }>()
22
- </script>
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
+ &times;
17
+ </button>
18
+ </template>
19
+
20
+ <script lang="ts" setup>
21
+ const emit = defineEmits<{ click: [] }>()
22
+ </script>
@@ -55,6 +55,45 @@ describe('BaseSpecFields', () => {
55
55
  ])
56
56
  })
57
57
 
58
+ it('truncates fractional input on integer fields (e.g. a pasted decimal)', async () => {
59
+ const wrapper = mountFields()
60
+ const lookback = wrapper.findAll('input[type="number"]')[1]
61
+ await lookback.setValue('2.5')
62
+ expect(wrapper.emitted('update')).toEqual([['lookback', 2]])
63
+ })
64
+
65
+ it('blocks decimal-separator and exponent keys on integer fields only', () => {
66
+ const wrapper = mountFields()
67
+ const [threshold, lookback] = wrapper.findAll('input[type="number"]')
68
+ const press = (input: typeof lookback, key: string) => {
69
+ const ev = new KeyboardEvent('keydown', { key, cancelable: true })
70
+ input.element.dispatchEvent(ev)
71
+ return ev.defaultPrevented
72
+ }
73
+ for (const key of ['.', ',', 'e', 'E']) {
74
+ expect(press(lookback, key)).toBe(true)
75
+ expect(press(threshold, key)).toBe(false)
76
+ }
77
+ expect(press(lookback, '5')).toBe(false)
78
+ })
79
+
80
+ it('refuses pastes with non-integer content on integer fields', () => {
81
+ const wrapper = mountFields()
82
+ const lookback = wrapper.findAll('input[type="number"]')[1]
83
+ const paste = (text: string) => {
84
+ // jsdom has no ClipboardEvent constructor — a plain cancelable Event
85
+ // with a stubbed clipboardData exercises the same handler path.
86
+ const ev = new Event('paste', { cancelable: true })
87
+ Object.defineProperty(ev, 'clipboardData', { value: { getData: () => text } })
88
+ lookback.element.dispatchEvent(ev)
89
+ return ev.defaultPrevented
90
+ }
91
+ expect(paste('0.5')).toBe(true)
92
+ expect(paste('2e3')).toBe(true)
93
+ expect(paste('12')).toBe(false)
94
+ expect(paste('-3')).toBe(false)
95
+ })
96
+
58
97
  it('never mutates params directly', async () => {
59
98
  const frozen = Object.freeze({ ...params })
60
99
  const wrapper = mount(BaseSpecFields, { props: { specs, params: frozen } })
@@ -32,9 +32,30 @@ function onCheckbox(key: string, e: Event) {
32
32
  // Mirror v-model.number: a parseable value becomes a number, anything else
33
33
  // (including a cleared field, which becomes '') passes through as the raw
34
34
  // string so the owner's "enter a valid number" validation can catch it.
35
- function onNumber(key: string, raw: string) {
35
+ // Integer specs truncate any fractional value (a pasted "2.5" becomes 2 —
36
+ // typed decimals never get this far, see onIntegerKeydown).
37
+ function onNumber(spec: SpecField, raw: string) {
36
38
  const n = parseFloat(raw)
37
- emit('update', key, Number.isNaN(n) ? raw : n)
39
+ if (Number.isNaN(n)) {
40
+ emit('update', spec.key, raw)
41
+ return
42
+ }
43
+ emit('update', spec.key, spec.type === 'integer' ? Math.trunc(n) : n)
44
+ }
45
+
46
+ // type="number" step=1 only flags decimals as :invalid — it doesn't stop them
47
+ // being typed — so integer fields swallow the decimal separators (and the
48
+ // exponent key) at the keyboard, and refuse pastes carrying anything beyond
49
+ // an optional sign and digits.
50
+ function onIntegerKeydown(e: KeyboardEvent) {
51
+ if (e.key === '.' || e.key === ',' || e.key === 'e' || e.key === 'E') {
52
+ e.preventDefault()
53
+ }
54
+ }
55
+
56
+ function onIntegerPaste(e: ClipboardEvent) {
57
+ const text = e.clipboardData?.getData('text') ?? ''
58
+ if (!/^-?\d*$/.test(text.trim())) e.preventDefault()
38
59
  }
39
60
  </script>
40
61
 
@@ -72,7 +93,10 @@ function onNumber(key: string, raw: string) {
72
93
  type="number"
73
94
  :step="spec.step ?? (spec.type === 'integer' ? 1 : 0.01)"
74
95
  :min="spec.min ?? undefined"
75
- @update:modelValue="(v: string) => onNumber(spec.key, v)"
96
+ :inputmode="spec.type === 'integer' ? 'numeric' : undefined"
97
+ @keydown="spec.type === 'integer' && onIntegerKeydown($event)"
98
+ @paste="spec.type === 'integer' && onIntegerPaste($event)"
99
+ @update:modelValue="(v: string) => onNumber(spec, v)"
76
100
  />
77
101
 
78
102
  <p v-if="spec.help" class="text-[11px] text-slate-400 mt-1">{{ spec.help }}</p>
@@ -1,56 +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
- })
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
+ })
@@ -1,49 +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>
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>
@@ -1,54 +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>
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>