mgv-backoffice 1.23.0 → 1.24.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,154 @@
1
+ <template>
2
+ <!--
3
+ Button-style single-select dropdown ("Select Social User ⌄"). Unlike
4
+ BaseSelect (a themed native <select>), this renders a trigger button plus
5
+ a floating menu, so the closed control shows a placeholder and a chevron
6
+ that rotates while open — matching the app's filter dropdowns.
7
+
8
+ Options are passed as data (not <option> slots) so the menu rows can carry
9
+ tooltips and disabled state. Selecting a row emits its `value` and closes
10
+ the menu; Escape and an outside click also close it.
11
+
12
+ Same `dark:`-variant note as BaseSelect: the slate skin ships static
13
+ light/dark classes so consumers get both themes without extra config.
14
+ -->
15
+ <div
16
+ ref="rootEl"
17
+ :class="block ? 'relative w-full' : 'relative inline-block'"
18
+ >
19
+ <button
20
+ type="button"
21
+ :class="[triggerClass, block ? 'w-full' : '']"
22
+ :disabled="disabled"
23
+ :aria-label="ariaLabel || undefined"
24
+ aria-haspopup="listbox"
25
+ :aria-expanded="open"
26
+ @click="toggle"
27
+ >
28
+ <span :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
29
+ {{ selectedOption ? selectedOption.label : placeholder }}
30
+ </span>
31
+ <ChevronDownIcon
32
+ class="w-5 h-5 shrink-0 text-slate-500 dark:text-slate-400 transition-transform"
33
+ :class="open ? 'rotate-180' : ''"
34
+ aria-hidden="true"
35
+ />
36
+ </button>
37
+
38
+ <ul
39
+ v-if="open"
40
+ role="listbox"
41
+ :aria-label="ariaLabel || placeholder"
42
+ class="absolute z-20 mt-1 w-full max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
43
+ >
44
+ <li
45
+ v-for="opt in options"
46
+ :key="String(opt.value)"
47
+ role="option"
48
+ :aria-selected="opt.value === modelValue"
49
+ :title="opt.title"
50
+ >
51
+ <button
52
+ type="button"
53
+ :disabled="opt.disabled"
54
+ :class="rowClass(opt)"
55
+ @click="select(opt)"
56
+ >
57
+ {{ opt.label }}
58
+ </button>
59
+ </li>
60
+ </ul>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup lang="ts">
65
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
66
+ import { ChevronDownIcon } from '@heroicons/vue/24/outline'
67
+ import { useEscapeKey } from '../composables/useEscapeKey'
68
+ import type { DropdownOption } from '../types/dropdown'
69
+
70
+ // NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
71
+ // emitted declarations, so consumers would see the erased constraint anyway.
72
+ // The update:modelValue payload is `any` so `v-model` on a union-typed ref
73
+ // type-checks; the actual payload is always one of the options' values.
74
+ interface Props {
75
+ options: readonly DropdownOption[]
76
+ modelValue?: string | number | null
77
+ /** Trigger text shown when nothing is selected. */
78
+ placeholder?: string
79
+ /** 'md' = px-4 py-2.5 (default, the app filter height); 'sm' = px-3 py-2. */
80
+ size?: 'sm' | 'md'
81
+ /** Full-width (w-full). Set false for an inline, content-width dropdown. */
82
+ block?: boolean
83
+ disabled?: boolean
84
+ /** Accessible name for the trigger/listbox when there is no visible label. */
85
+ ariaLabel?: string
86
+ }
87
+
88
+ const props = withDefaults(defineProps<Props>(), {
89
+ modelValue: null,
90
+ placeholder: 'Select',
91
+ size: 'md',
92
+ block: true,
93
+ disabled: false,
94
+ ariaLabel: '',
95
+ })
96
+
97
+ const emit = defineEmits<{
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ (e: 'update:modelValue', value: any): void
100
+ }>()
101
+
102
+ const open = ref(false)
103
+ const rootEl = ref<HTMLElement | null>(null)
104
+
105
+ const selectedOption = computed(() =>
106
+ props.options.find((o) => o.value === props.modelValue) ?? null,
107
+ )
108
+
109
+ const triggerClass = computed(() => {
110
+ const padding = props.size === 'sm' ? 'px-3 py-2' : 'px-4 py-2.5'
111
+ return (
112
+ 'flex items-center justify-between gap-2 rounded-lg border text-left text-sm transition-colors ' +
113
+ 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
114
+ 'hover:bg-slate-50 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
115
+ 'disabled:opacity-50 disabled:cursor-not-allowed ' +
116
+ padding
117
+ )
118
+ })
119
+
120
+ function rowClass(opt: DropdownOption): string {
121
+ const selected = opt.value === props.modelValue
122
+ const base =
123
+ 'block w-full px-4 py-2 text-left text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
124
+ const fill = selected
125
+ ? 'bg-emerald-500 text-white'
126
+ : 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
127
+ return `${base} ${fill}`
128
+ }
129
+
130
+ function toggle() {
131
+ if (props.disabled) return
132
+ open.value = !open.value
133
+ }
134
+
135
+ function select(opt: DropdownOption) {
136
+ if (opt.disabled) return
137
+ emit('update:modelValue', opt.value)
138
+ open.value = false
139
+ }
140
+
141
+ useEscapeKey(() => {
142
+ open.value = false
143
+ })
144
+
145
+ function onDocumentClick(e: MouseEvent) {
146
+ if (!open.value) return
147
+ if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
148
+ open.value = false
149
+ }
150
+ }
151
+
152
+ onMounted(() => document.addEventListener('click', onDocumentClick))
153
+ onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
154
+ </script>
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export { default as BaseNotificationPanel } from './components/BaseNotificationP
37
37
  // Components — extracted from TradeAutomation
38
38
  export { default as BaseInput } from './components/BaseInput.vue'
39
39
  export { default as BaseSelect } from './components/BaseSelect.vue'
40
+ export { default as BaseDropdown } from './components/BaseDropdown.vue'
40
41
  export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
41
42
  export { default as BaseTable } from './components/BaseTable.vue'
42
43
  export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
@@ -73,6 +74,7 @@ export type { EntityPickerItem } from './types/entityPicker'
73
74
  export type { LoginCredentials } from './types/auth'
74
75
  export type { NotificationItem } from './types/notification'
75
76
  export type { SegmentedOption } from './types/segmented'
77
+ export type { DropdownOption } from './types/dropdown'
76
78
  export type { TableColumn } from './types/table'
77
79
  export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
78
80
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * One choice in a BaseDropdown. `value` is what the dropdown emits, `label`
3
+ * what the trigger and the menu row show; `title` becomes the row's tooltip
4
+ * and `disabled` makes the row unselectable.
5
+ *
6
+ * Generic so callers with union-typed values ('7D' | '30D' | …) keep full
7
+ * type-safety through v-model.
8
+ */
9
+ export interface DropdownOption<T extends string | number = string | number> {
10
+ value: T
11
+ label: string
12
+ title?: string
13
+ disabled?: boolean
14
+ }