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
@@ -15,6 +15,6 @@ export declare function buildSpecParams(specs: readonly SpecField[] | undefined,
15
15
  * '' under number coercion), or null when every numeric field holds a number.
16
16
  * A spec whose `default` is null is OPTIONAL: blank means "knob disabled" and
17
17
  * must pass; a non-blank value on an optional field is still validated as a
18
- * number.
18
+ * number. Integer specs additionally reject fractional values.
19
19
  */
20
20
  export declare function firstInvalidNumericSpec(specs: readonly SpecField[] | undefined, params: Record<string, SpecFieldValue>): string | null;
package/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "mgv-backoffice",
3
- "version": "1.36.0",
4
- "description": "Shared Vue 3 UI component library",
5
- "type": "module",
6
- "main": "./dist/ui-lib.umd.cjs",
7
- "module": "./dist/ui-lib.js",
8
- "types": "./dist/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/ui-lib.js",
13
- "require": "./dist/ui-lib.umd.cjs"
14
- },
15
- "./dist/style.css": "./dist/ui-lib.css",
16
- "./tailwind.safelist": "./tailwind.safelist.js",
17
- "./tailwind.safelist.css": "./tailwind.safelist.css"
18
- },
19
- "files": [
20
- "dist",
21
- "src",
22
- "tailwind.safelist.js",
23
- "tailwind.safelist.css"
24
- ],
25
- "scripts": {
26
- "safelist": "node scripts/generate-safelist.mjs",
27
- "prebuild": "npm run safelist",
28
- "build": "vite build",
29
- "dev": "vite build --watch",
30
- "test": "vitest run",
31
- "test:watch": "vitest"
32
- },
33
- "peerDependencies": {
34
- "@heroicons/vue": "^2.0.0",
35
- "vue": "^3.5.0",
36
- "vue-router": "^5.0.0"
37
- },
38
- "devDependencies": {
39
- "@heroicons/vue": "^2.2.0",
40
- "@tailwindcss/vite": "^4.3.1",
41
- "@vitejs/plugin-vue": "^6.0.7",
42
- "@vue/language-core": "^3.3.5",
43
- "@vue/test-utils": "^2.4.11",
44
- "jsdom": "^29.1.1",
45
- "sass": "^1.101.0",
46
- "tailwindcss": "^4.3.1",
47
- "typescript": "^6.0.3",
48
- "vite": "^8.1.0",
49
- "vite-plugin-dts": "^5.0.3",
50
- "vitest": "^4.1.9",
51
- "vue": "^3.5.38",
52
- "vue-router": "^5.1.0"
53
- }
54
- }
1
+ {
2
+ "name": "mgv-backoffice",
3
+ "version": "1.36.2",
4
+ "description": "Shared Vue 3 UI component library",
5
+ "type": "module",
6
+ "main": "./dist/ui-lib.umd.cjs",
7
+ "module": "./dist/ui-lib.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/ui-lib.js",
13
+ "require": "./dist/ui-lib.umd.cjs"
14
+ },
15
+ "./dist/style.css": "./dist/ui-lib.css",
16
+ "./tailwind.safelist": "./tailwind.safelist.js",
17
+ "./tailwind.safelist.css": "./tailwind.safelist.css"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "tailwind.safelist.js",
23
+ "tailwind.safelist.css"
24
+ ],
25
+ "scripts": {
26
+ "safelist": "node scripts/generate-safelist.mjs",
27
+ "prebuild": "npm run safelist",
28
+ "build": "vite build",
29
+ "dev": "vite build --watch",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest"
32
+ },
33
+ "peerDependencies": {
34
+ "@heroicons/vue": "^2.0.0",
35
+ "vue": "^3.5.0",
36
+ "vue-router": "^5.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@heroicons/vue": "^2.2.0",
40
+ "@tailwindcss/vite": "^4.3.1",
41
+ "@vitejs/plugin-vue": "^6.0.7",
42
+ "@vue/language-core": "^3.3.5",
43
+ "@vue/test-utils": "^2.4.11",
44
+ "jsdom": "^29.1.1",
45
+ "sass": "^1.101.0",
46
+ "tailwindcss": "^4.3.1",
47
+ "typescript": "^6.0.3",
48
+ "vite": "^8.1.0",
49
+ "vite-plugin-dts": "^5.0.3",
50
+ "vitest": "^4.1.9",
51
+ "vue": "^3.5.38",
52
+ "vue-router": "^5.1.0"
53
+ }
54
+ }
@@ -1,104 +1,104 @@
1
- <template>
2
- <!--
3
- Inline notice panel (error / warning / success / info).
4
-
5
- v2 (1.27.0): rewritten from the legacy dynamic-class version —
6
- • theme-aware via the shared `isDark` ref (same pattern as the other
7
- extracted components; no runtime-composed class names, so every
8
- class survives Tailwind's static scan)
9
- • optional default slot for body content below the title
10
- • `compact` variant for slim in-form warnings (text-xs, small icon)
11
- Backward compatible: `<BaseAlert title="..." :color="AlertEnum.X" />`
12
- renders the same single-line alert as before, mb-4 included.
13
- -->
14
- <div
15
- role="alert"
16
- class="flex gap-3 mb-4 rounded-lg border text-sm transition-colors"
17
- :class="[compact ? 'items-start px-3 py-2 text-xs' : 'items-start p-4', palette]"
18
- >
19
- <component
20
- :is="iconComponent"
21
- class="shrink-0"
22
- :class="[compact ? 'w-4 h-4 mt-px' : 'w-5 h-5 mt-0.5', iconColorClass]"
23
- aria-hidden="true"
24
- />
25
- <div class="min-w-0 flex-1">
26
- <p v-if="title" class="font-medium">{{ title }}</p>
27
- <!-- Body — caller-styled free-form content under the title. -->
28
- <slot />
29
- </div>
30
- </div>
31
- </template>
32
-
33
- <script lang="ts" setup>
34
- import {
35
- CheckCircleIcon,
36
- ExclamationCircleIcon,
37
- ExclamationTriangleIcon,
38
- InformationCircleIcon,
39
- } from '@heroicons/vue/24/outline'
40
- import { AlertEnum } from '../enums/AlertEnum'
41
- import { computed } from 'vue'
42
- import { useTheme } from '../composables/useTheme'
43
-
44
- interface Props {
45
- /** Bold headline. Optional when the slot carries the whole message. */
46
- title?: string
47
- /** Alert mode — drives palette and icon. Defaults to ERROR. */
48
- color?: AlertEnum
49
- /** Slim variant for in-form warnings: text-xs, smaller icon/padding. */
50
- compact?: boolean
51
- }
52
-
53
- const props = withDefaults(defineProps<Props>(), {
54
- title: '',
55
- color: AlertEnum.ERROR,
56
- compact: false,
57
- })
58
-
59
- const { isDark } = useTheme()
60
-
61
- // Literal palette table so Tailwind's scanner sees every class.
62
- const PALETTES: Record<AlertEnum, { light: string; dark: string }> = {
63
- [AlertEnum.ERROR]: {
64
- light: 'bg-red-50 border-red-200 text-red-700',
65
- dark: 'bg-red-500/10 border-red-500/30 text-red-300',
66
- },
67
- [AlertEnum.WARNING]: {
68
- light: 'bg-amber-50 border-amber-200 text-amber-700',
69
- dark: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
70
- },
71
- [AlertEnum.SUCCESS]: {
72
- light: 'bg-emerald-50 border-emerald-200 text-emerald-700',
73
- dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
74
- },
75
- [AlertEnum.INFO]: {
76
- light: 'bg-sky-50 border-sky-200 text-sky-700',
77
- dark: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
78
- },
79
- }
80
-
81
- const palette = computed(() =>
82
- isDark.value ? PALETTES[props.color].dark : PALETTES[props.color].light,
83
- )
84
-
85
- // Icon + tint follow the alert mode so an error doesn't render with a
86
- // neutral info glyph.
87
- const iconComponent = computed(() => {
88
- switch (props.color) {
89
- case AlertEnum.SUCCESS: return CheckCircleIcon
90
- case AlertEnum.WARNING: return ExclamationTriangleIcon
91
- case AlertEnum.INFO: return InformationCircleIcon
92
- default: return ExclamationCircleIcon
93
- }
94
- })
95
-
96
- const iconColorClass = computed(() => {
97
- switch (props.color) {
98
- case AlertEnum.SUCCESS: return 'text-emerald-500'
99
- case AlertEnum.WARNING: return 'text-amber-500'
100
- case AlertEnum.INFO: return 'text-sky-500'
101
- default: return 'text-red-500'
102
- }
103
- })
104
- </script>
1
+ <template>
2
+ <!--
3
+ Inline notice panel (error / warning / success / info).
4
+
5
+ v2 (1.27.0): rewritten from the legacy dynamic-class version —
6
+ • theme-aware via the shared `isDark` ref (same pattern as the other
7
+ extracted components; no runtime-composed class names, so every
8
+ class survives Tailwind's static scan)
9
+ • optional default slot for body content below the title
10
+ • `compact` variant for slim in-form warnings (text-xs, small icon)
11
+ Backward compatible: `<BaseAlert title="..." :color="AlertEnum.X" />`
12
+ renders the same single-line alert as before, mb-4 included.
13
+ -->
14
+ <div
15
+ role="alert"
16
+ class="flex gap-3 mb-4 rounded-lg border text-sm transition-colors"
17
+ :class="[compact ? 'items-start px-3 py-2 text-xs' : 'items-start p-4', palette]"
18
+ >
19
+ <component
20
+ :is="iconComponent"
21
+ class="shrink-0"
22
+ :class="[compact ? 'w-4 h-4 mt-px' : 'w-5 h-5 mt-0.5', iconColorClass]"
23
+ aria-hidden="true"
24
+ />
25
+ <div class="min-w-0 flex-1">
26
+ <p v-if="title" class="font-medium">{{ title }}</p>
27
+ <!-- Body — caller-styled free-form content under the title. -->
28
+ <slot />
29
+ </div>
30
+ </div>
31
+ </template>
32
+
33
+ <script lang="ts" setup>
34
+ import {
35
+ CheckCircleIcon,
36
+ ExclamationCircleIcon,
37
+ ExclamationTriangleIcon,
38
+ InformationCircleIcon,
39
+ } from '@heroicons/vue/24/outline'
40
+ import { AlertEnum } from '../enums/AlertEnum'
41
+ import { computed } from 'vue'
42
+ import { useTheme } from '../composables/useTheme'
43
+
44
+ interface Props {
45
+ /** Bold headline. Optional when the slot carries the whole message. */
46
+ title?: string
47
+ /** Alert mode — drives palette and icon. Defaults to ERROR. */
48
+ color?: AlertEnum
49
+ /** Slim variant for in-form warnings: text-xs, smaller icon/padding. */
50
+ compact?: boolean
51
+ }
52
+
53
+ const props = withDefaults(defineProps<Props>(), {
54
+ title: '',
55
+ color: AlertEnum.ERROR,
56
+ compact: false,
57
+ })
58
+
59
+ const { isDark } = useTheme()
60
+
61
+ // Literal palette table so Tailwind's scanner sees every class.
62
+ const PALETTES: Record<AlertEnum, { light: string; dark: string }> = {
63
+ [AlertEnum.ERROR]: {
64
+ light: 'bg-red-50 border-red-200 text-red-700',
65
+ dark: 'bg-red-500/10 border-red-500/30 text-red-300',
66
+ },
67
+ [AlertEnum.WARNING]: {
68
+ light: 'bg-amber-50 border-amber-200 text-amber-700',
69
+ dark: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
70
+ },
71
+ [AlertEnum.SUCCESS]: {
72
+ light: 'bg-emerald-50 border-emerald-200 text-emerald-700',
73
+ dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
74
+ },
75
+ [AlertEnum.INFO]: {
76
+ light: 'bg-sky-50 border-sky-200 text-sky-700',
77
+ dark: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
78
+ },
79
+ }
80
+
81
+ const palette = computed(() =>
82
+ isDark.value ? PALETTES[props.color].dark : PALETTES[props.color].light,
83
+ )
84
+
85
+ // Icon + tint follow the alert mode so an error doesn't render with a
86
+ // neutral info glyph.
87
+ const iconComponent = computed(() => {
88
+ switch (props.color) {
89
+ case AlertEnum.SUCCESS: return CheckCircleIcon
90
+ case AlertEnum.WARNING: return ExclamationTriangleIcon
91
+ case AlertEnum.INFO: return InformationCircleIcon
92
+ default: return ExclamationCircleIcon
93
+ }
94
+ })
95
+
96
+ const iconColorClass = computed(() => {
97
+ switch (props.color) {
98
+ case AlertEnum.SUCCESS: return 'text-emerald-500'
99
+ case AlertEnum.WARNING: return 'text-amber-500'
100
+ case AlertEnum.INFO: return 'text-sky-500'
101
+ default: return 'text-red-500'
102
+ }
103
+ })
104
+ </script>
@@ -1,16 +1,26 @@
1
1
  <template>
2
2
  <span v-if="hasDefaultSlot" :class="badgeClasses">
3
- <slot></slot>
3
+ <BadgeContent />
4
4
  </span>
5
5
  </template>
6
6
 
7
7
  <script lang="ts" setup>
8
- import { computed, useSlots } from 'vue'
8
+ import { computed, createTextVNode, Text, useSlots } from 'vue'
9
9
  import { ColorsEnums } from "../enums/ColorsEnums";
10
10
 
11
11
  const slots = useSlots()
12
12
  const hasDefaultSlot = computed(() => !!slots.default)
13
13
 
14
+ // Badge labels are often raw enum names (PROFIT_TARGET, RSI_REVERT); render
15
+ // their underscores as spaces so callers don't have to humanize at every call
16
+ // site. Only plain text nodes are rewritten — element children pass through.
17
+ const BadgeContent = () =>
18
+ (slots.default?.() ?? []).map((node) =>
19
+ node.type === Text && typeof node.children === 'string'
20
+ ? createTextVNode(node.children.replace(/_/g, ' '))
21
+ : node
22
+ )
23
+
14
24
  const props = defineProps({
15
25
  color: {
16
26
  type: String,
@@ -1,51 +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
- })
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
+ })
@@ -1,73 +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>
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>