mgv-backoffice 1.41.0 → 1.42.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.
@@ -0,0 +1,10 @@
1
+ import { VNode } from 'vue';
2
+ /** Raw enum names (BLUE_SKY, PROFIT_TARGET) → display text (BLUE SKY). */
3
+ export declare function humanizeEnum(value: string | null | undefined): string;
4
+ /**
5
+ * Rewrites underscores to spaces in slot text so callers can pass raw enum
6
+ * names. Plain text nodes are rewritten and fragments (v-for / v-if) are
7
+ * walked; elements pass through untouched unless their tag is in `tags`, in
8
+ * which case their string children are rewritten too (e.g. `<option>`).
9
+ */
10
+ export declare function humanizeTextVNodes(nodes: VNode[], tags?: readonly string[]): VNode[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.41.0",
3
+ "version": "1.42.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -5,8 +5,9 @@
5
5
  </template>
6
6
 
7
7
  <script lang="ts" setup>
8
- import { computed, createTextVNode, Text, useSlots } from 'vue'
8
+ import { computed, useSlots } from 'vue'
9
9
  import { ColorsEnums } from "../enums/ColorsEnums";
10
+ import { humanizeTextVNodes } from '../utils/humanize'
10
11
 
11
12
  const slots = useSlots()
12
13
  const hasDefaultSlot = computed(() => !!slots.default)
@@ -14,12 +15,7 @@ const hasDefaultSlot = computed(() => !!slots.default)
14
15
  // Badge labels are often raw enum names (PROFIT_TARGET, RSI_REVERT); render
15
16
  // their underscores as spaces so callers don't have to humanize at every call
16
17
  // 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
- )
18
+ const BadgeContent = () => humanizeTextVNodes(slots.default?.() ?? [])
23
19
 
24
20
  const props = defineProps({
25
21
  color: {
@@ -35,3 +35,23 @@ describe('BaseSelect', () => {
35
35
  expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['A', 'B'])
36
36
  })
37
37
  })
38
+
39
+ describe('BaseSelect humanize', () => {
40
+ const enumSlots = {
41
+ default: '<option value="BLUE_SKY">BLUE_SKY</option><option v-for="o in [\'TICK_TOCK\']" :key="o" :value="o">{{ o }}</option>',
42
+ }
43
+
44
+ it('shows underscores as spaces but keeps raw option values', async () => {
45
+ const wrapper = mount(BaseSelect, { slots: enumSlots })
46
+ const options = wrapper.findAll('option')
47
+ expect(options.map((o) => o.text())).toEqual(['BLUE SKY', 'TICK TOCK'])
48
+ expect(options.map((o) => o.element.value)).toEqual(['BLUE_SKY', 'TICK_TOCK'])
49
+ await wrapper.find('select').setValue('TICK_TOCK')
50
+ expect(wrapper.emitted('update:modelValue')).toEqual([['TICK_TOCK']])
51
+ })
52
+
53
+ it('can be turned off with humanize=false', () => {
54
+ const wrapper = mount(BaseSelect, { props: { humanize: false }, slots: enumSlots })
55
+ expect(wrapper.findAll('option').map((o) => o.text())).toEqual(['BLUE_SKY', 'TICK_TOCK'])
56
+ })
57
+ })
@@ -3,11 +3,11 @@
3
3
  <select
4
4
  v-bind="$attrs"
5
5
  :value="modelValue"
6
- class="w-full appearance-none bg-none rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
6
+ class="base-select w-full appearance-none bg-none rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
7
7
  :class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
8
8
  @change="onChange"
9
9
  >
10
- <slot />
10
+ <SlotContent />
11
11
  </select>
12
12
  <ChevronDownIcon
13
13
  class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 dark:text-slate-400"
@@ -25,7 +25,9 @@
25
25
  * through. Same `dark:`-variant note as BaseInput.
26
26
  *
27
27
  */
28
+ import { useSlots } from 'vue'
28
29
  import { ChevronDownIcon } from '@heroicons/vue/24/outline'
30
+ import { humanizeTextVNodes } from '../utils/humanize'
29
31
 
30
32
  // Attrs (id, name, disabled, extra classes) go to the <select>, not the
31
33
  // chevron wrapper. `bg-none` drops any arrow a forms reset (e.g. Flowbite /
@@ -43,14 +45,25 @@ interface Props {
43
45
  size?: 'sm' | 'md'
44
46
  /** Full-width (w-full). Set false for inline selects. */
45
47
  block?: boolean
48
+ /** Render underscores in option text as spaces (BLUE_SKY → BLUE SKY). Values are untouched. */
49
+ humanize?: boolean
46
50
  }
47
51
 
48
- withDefaults(defineProps<Props>(), {
52
+ const props = withDefaults(defineProps<Props>(), {
49
53
  modelValue: undefined,
50
54
  size: 'sm',
51
55
  block: true,
56
+ humanize: true,
52
57
  })
53
58
 
59
+ // Options are often raw enum names; only their visible text is rewritten, the
60
+ // `value` attribute (what v-model receives) stays the raw enum.
61
+ const slots = useSlots()
62
+ const SlotContent = () => {
63
+ const nodes = slots.default?.() ?? []
64
+ return props.humanize ? humanizeTextVNodes(nodes, ['option', 'optgroup']) : nodes
65
+ }
66
+
54
67
  const emit = defineEmits<{
55
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
69
  (e: 'update:modelValue', value: any): void
package/src/index.ts CHANGED
@@ -149,5 +149,6 @@ export {
149
149
  isValidBase64,
150
150
  } from './utils/validate'
151
151
  export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
152
+ export { humanizeEnum, humanizeTextVNodes } from './utils/humanize'
152
153
  export { computePnL } from './utils/pnl'
153
154
  export type { PnL, PnLInputs } from './utils/pnl'
package/src/style.css CHANGED
@@ -88,3 +88,74 @@ html .dp--theme-dark {
88
88
  --dp-marker-color: #ef4444;
89
89
  --dp-tooltip-color: #1e293b;
90
90
  }
91
+
92
+ /* BaseSelect: rounded, themed dropdown panel. Opt-in customizable <select>
93
+ (appearance: base-select, Chromium 135+); other browsers keep the native
94
+ popup, which can't be styled. The button skin comes from BaseSelect's
95
+ utilities, and its own ChevronDownIcon replaces the built-in picker icon. */
96
+ @supports (appearance: base-select) {
97
+ .base-select,
98
+ .base-select::picker(select) {
99
+ appearance: base-select;
100
+ }
101
+
102
+ .base-select::picker-icon {
103
+ display: none;
104
+ }
105
+
106
+ .base-select::picker(select) {
107
+ margin-top: 0.25rem;
108
+ padding: 0.25rem;
109
+ border: 1px solid var(--color-slate-200);
110
+ border-radius: 0.75rem;
111
+ background-color: #fff;
112
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
113
+ }
114
+
115
+ .base-select option {
116
+ padding: 0.375rem 0.625rem;
117
+ border-radius: 0.5rem;
118
+ color: var(--color-slate-900);
119
+ cursor: pointer;
120
+ }
121
+
122
+ .base-select option::checkmark {
123
+ display: none;
124
+ }
125
+
126
+ .base-select option:hover,
127
+ .base-select option:focus-visible {
128
+ background-color: var(--color-slate-100);
129
+ outline: none;
130
+ }
131
+
132
+ .base-select option:checked {
133
+ background-color: var(--color-emerald-50);
134
+ color: var(--color-emerald-700);
135
+ font-weight: 500;
136
+ }
137
+
138
+ .base-select option:disabled {
139
+ color: var(--color-slate-400);
140
+ cursor: default;
141
+ }
142
+
143
+ .dark .base-select::picker(select) {
144
+ border-color: var(--color-slate-700);
145
+ background-color: var(--color-slate-900);
146
+ }
147
+
148
+ .dark .base-select option {
149
+ color: var(--color-slate-100);
150
+ }
151
+
152
+ .dark .base-select option:hover,
153
+ .dark .base-select option:focus-visible {
154
+ background-color: var(--color-slate-800);
155
+ }
156
+
157
+ .dark .base-select option:checked {
158
+ background-color: rgb(16 185 129 / 0.15);
159
+ color: var(--color-emerald-300);
160
+ }
161
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { h, Fragment, createTextVNode } from 'vue'
3
+ import { humanizeEnum, humanizeTextVNodes } from './humanize'
4
+
5
+ describe('humanizeEnum', () => {
6
+ it('replaces every underscore with a space', () => {
7
+ expect(humanizeEnum('BLUE_SKY')).toBe('BLUE SKY')
8
+ expect(humanizeEnum('A_B_C')).toBe('A B C')
9
+ })
10
+
11
+ it('returns an empty string for null / undefined', () => {
12
+ expect(humanizeEnum(null)).toBe('')
13
+ expect(humanizeEnum(undefined)).toBe('')
14
+ })
15
+ })
16
+
17
+ describe('humanizeTextVNodes', () => {
18
+ it('rewrites text nodes and walks fragments', () => {
19
+ const [text, frag] = humanizeTextVNodes([
20
+ createTextVNode('PROFIT_TARGET'),
21
+ h(Fragment, [createTextVNode('RSI_REVERT')]),
22
+ ])
23
+ expect(text.children).toBe('PROFIT TARGET')
24
+ expect((frag.children as any[])[0].children).toBe('RSI REVERT')
25
+ })
26
+
27
+ it('leaves elements alone unless their tag is listed', () => {
28
+ const span = h('span', 'KEEP_ME')
29
+ expect(humanizeTextVNodes([span])[0]).toBe(span)
30
+
31
+ const [opt] = humanizeTextVNodes([h('option', { value: 'BLUE_SKY' }, 'BLUE_SKY')], ['option'])
32
+ expect(opt.children).toBe('BLUE SKY')
33
+ expect(opt.props?.value).toBe('BLUE_SKY')
34
+ })
35
+ })
@@ -0,0 +1,35 @@
1
+ import { cloneVNode, createTextVNode, Fragment, Text, type VNode } from 'vue'
2
+
3
+ /** Raw enum names (BLUE_SKY, PROFIT_TARGET) → display text (BLUE SKY). */
4
+ export function humanizeEnum(value: string | null | undefined): string {
5
+ return value == null ? '' : String(value).replace(/_/g, ' ')
6
+ }
7
+
8
+ /**
9
+ * Rewrites underscores to spaces in slot text so callers can pass raw enum
10
+ * names. Plain text nodes are rewritten and fragments (v-for / v-if) are
11
+ * walked; elements pass through untouched unless their tag is in `tags`, in
12
+ * which case their string children are rewritten too (e.g. `<option>`).
13
+ */
14
+ export function humanizeTextVNodes(nodes: VNode[], tags: readonly string[] = []): VNode[] {
15
+ return nodes.map((node) => {
16
+ if (node.type === Text && typeof node.children === 'string') {
17
+ return createTextVNode(humanizeEnum(node.children))
18
+ }
19
+ if (node.type === Fragment && Array.isArray(node.children)) {
20
+ const clone = cloneVNode(node)
21
+ clone.children = humanizeTextVNodes(node.children as VNode[], tags)
22
+ return clone
23
+ }
24
+ if (typeof node.type === 'string' && tags.includes(node.type)) {
25
+ const clone = cloneVNode(node)
26
+ if (typeof node.children === 'string') {
27
+ clone.children = humanizeEnum(node.children)
28
+ } else if (Array.isArray(node.children)) {
29
+ clone.children = humanizeTextVNodes(node.children as VNode[], tags)
30
+ }
31
+ return clone
32
+ }
33
+ return node
34
+ })
35
+ }