mgv-backoffice 1.21.0 → 1.22.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,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>
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'
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One choice in a BaseSegmentedControl. `value` is what the control emits,
3
+ * `label` what the button shows; `title` becomes the button's tooltip.
4
+ * Generic so callers with union-typed values ('all' | 'STOCK' | …) keep
5
+ * full type-safety through v-model.
6
+ */
7
+ export interface SegmentedOption<T extends string | number = string | number> {
8
+ value: T
9
+ label: string
10
+ title?: string
11
+ }
@@ -0,0 +1,25 @@
1
+ /** Which input a BaseSpecFields entry renders. */
2
+ export type SpecFieldType = 'decimal' | 'integer' | 'boolean' | 'select'
3
+
4
+ /**
5
+ * One field of a spec-driven form: the field's key in the value map, its
6
+ * label, which input to render, and optional validation/rendering hints.
7
+ * Members are loose (`| null` allowed) so backend-described catalogues can be
8
+ * passed straight through without mapping.
9
+ */
10
+ export interface SpecField {
11
+ key: string
12
+ label: string
13
+ type: SpecFieldType
14
+ /** Choices for `type: 'select'`. */
15
+ options?: string[] | null
16
+ /** Step for numeric inputs; defaults to 1 (integer) / 0.01 (decimal). */
17
+ step?: number | null
18
+ /** Minimum for numeric inputs. */
19
+ min?: number | null
20
+ /** Help text rendered under the field. */
21
+ help?: string | null
22
+ }
23
+
24
+ /** A field's current value in the owner's form state. */
25
+ export type SpecFieldValue = number | boolean | string
@@ -0,0 +1,8 @@
1
+ /**
2
+ * One header cell of a BaseTable. `align: 'right'` right-aligns the header
3
+ * (numeric columns); body cells are the caller's markup and align themselves.
4
+ */
5
+ export interface TableColumn {
6
+ label: string
7
+ align?: 'left' | 'right'
8
+ }