mgv-backoffice 1.42.0 → 1.43.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.
- package/README.md +2000 -1999
- package/dist/components/BaseDropdown.vue.d.ts +8 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +16 -11
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseDropdown.test.ts +102 -87
- package/src/components/BaseDropdown.vue +188 -172
package/package.json
CHANGED
|
@@ -1,87 +1,102 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
3
|
-
import BaseDropdown from './BaseDropdown.vue'
|
|
4
|
-
import type { DropdownOption } from '../types/dropdown'
|
|
5
|
-
|
|
6
|
-
const options: DropdownOption[] = [
|
|
7
|
-
{ value: '1', label: 'Alice' },
|
|
8
|
-
{ value: '2', label: 'Bob', title: 'Bob the builder' },
|
|
9
|
-
{ value: '3', label: 'Carol', disabled: true },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
describe('BaseDropdown', () => {
|
|
13
|
-
it('shows the placeholder when nothing is selected', () => {
|
|
14
|
-
const wrapper = mount(BaseDropdown, {
|
|
15
|
-
props: { options, modelValue: null, placeholder: 'Select Social User' },
|
|
16
|
-
})
|
|
17
|
-
const trigger = wrapper.get('button')
|
|
18
|
-
expect(trigger.text()).toContain('Select Social User')
|
|
19
|
-
expect(trigger.attributes('aria-expanded')).toBe('false')
|
|
20
|
-
// Menu is closed until the trigger is clicked.
|
|
21
|
-
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('shows the selected option label instead of the placeholder', () => {
|
|
25
|
-
const wrapper = mount(BaseDropdown, {
|
|
26
|
-
props: { options, modelValue: '2', placeholder: 'Select Social User' },
|
|
27
|
-
})
|
|
28
|
-
expect(wrapper.get('button').text()).toContain('Bob')
|
|
29
|
-
expect(wrapper.get('button').text()).not.toContain('Select Social User')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('opens the menu and renders one row per option on click', async () => {
|
|
33
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
34
|
-
await wrapper.get('button').trigger('click')
|
|
35
|
-
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
|
|
36
|
-
const rows = wrapper.findAll('[role="option"]')
|
|
37
|
-
expect(rows.map((r) => r.text())).toEqual(['Alice', 'Bob', 'Carol'])
|
|
38
|
-
expect(rows[1].attributes('title')).toBe('Bob the builder')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('emits update:modelValue and closes when a row is chosen', async () => {
|
|
42
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
43
|
-
await wrapper.get('button').trigger('click')
|
|
44
|
-
await wrapper.findAll('[role="option"] button')[1].trigger('click')
|
|
45
|
-
expect(wrapper.emitted('update:modelValue')).toEqual([['2']])
|
|
46
|
-
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('does not emit for a disabled row', async () => {
|
|
50
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
51
|
-
await wrapper.get('button').trigger('click')
|
|
52
|
-
await wrapper.findAll('[role="option"] button')[2].trigger('click')
|
|
53
|
-
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('marks the selected row with aria-selected and the active fill', async () => {
|
|
57
|
-
const wrapper = mount(BaseDropdown, { props: { options, modelValue: '1' } })
|
|
58
|
-
await wrapper.get('button').trigger('click')
|
|
59
|
-
const rows = wrapper.findAll('[role="option"]')
|
|
60
|
-
expect(rows[0].attributes('aria-selected')).toBe('true')
|
|
61
|
-
expect(rows[1].attributes('aria-selected')).toBe('false')
|
|
62
|
-
expect(rows[0].get('button').classes()).toContain('bg-emerald-500')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('replaces the default trigger skin when triggerClass is set', () => {
|
|
66
|
-
const wrapper = mount(BaseDropdown, {
|
|
67
|
-
props: { options, modelValue: '1', triggerClass: 'rounded border bg-red-100 text-red-700 px-1.5 py-0.5' },
|
|
68
|
-
})
|
|
69
|
-
const classes = wrapper.get('button').classes()
|
|
70
|
-
// Custom skin applied…
|
|
71
|
-
expect(classes).toContain('bg-red-100')
|
|
72
|
-
expect(classes).toContain('text-red-700')
|
|
73
|
-
// …and the default slate skin dropped (layout/focus classes remain).
|
|
74
|
-
expect(classes).not.toContain('border-slate-300')
|
|
75
|
-
expect(classes).not.toContain('bg-white')
|
|
76
|
-
expect(classes).toContain('cursor-pointer')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('sizes the chevron via chevronClass', () => {
|
|
80
|
-
const wrapper = mount(BaseDropdown, {
|
|
81
|
-
props: { options, modelValue: '1', chevronClass: 'w-3 h-3' },
|
|
82
|
-
})
|
|
83
|
-
const chevron = wrapper.get('button svg')
|
|
84
|
-
expect(chevron.classes()).toContain('w-3')
|
|
85
|
-
expect(chevron.classes()).not.toContain('w-5')
|
|
86
|
-
})
|
|
87
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseDropdown from './BaseDropdown.vue'
|
|
4
|
+
import type { DropdownOption } from '../types/dropdown'
|
|
5
|
+
|
|
6
|
+
const options: DropdownOption[] = [
|
|
7
|
+
{ value: '1', label: 'Alice' },
|
|
8
|
+
{ value: '2', label: 'Bob', title: 'Bob the builder' },
|
|
9
|
+
{ value: '3', label: 'Carol', disabled: true },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseDropdown', () => {
|
|
13
|
+
it('shows the placeholder when nothing is selected', () => {
|
|
14
|
+
const wrapper = mount(BaseDropdown, {
|
|
15
|
+
props: { options, modelValue: null, placeholder: 'Select Social User' },
|
|
16
|
+
})
|
|
17
|
+
const trigger = wrapper.get('button')
|
|
18
|
+
expect(trigger.text()).toContain('Select Social User')
|
|
19
|
+
expect(trigger.attributes('aria-expanded')).toBe('false')
|
|
20
|
+
// Menu is closed until the trigger is clicked.
|
|
21
|
+
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('shows the selected option label instead of the placeholder', () => {
|
|
25
|
+
const wrapper = mount(BaseDropdown, {
|
|
26
|
+
props: { options, modelValue: '2', placeholder: 'Select Social User' },
|
|
27
|
+
})
|
|
28
|
+
expect(wrapper.get('button').text()).toContain('Bob')
|
|
29
|
+
expect(wrapper.get('button').text()).not.toContain('Select Social User')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('opens the menu and renders one row per option on click', async () => {
|
|
33
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
34
|
+
await wrapper.get('button').trigger('click')
|
|
35
|
+
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
|
|
36
|
+
const rows = wrapper.findAll('[role="option"]')
|
|
37
|
+
expect(rows.map((r) => r.text())).toEqual(['Alice', 'Bob', 'Carol'])
|
|
38
|
+
expect(rows[1].attributes('title')).toBe('Bob the builder')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('emits update:modelValue and closes when a row is chosen', async () => {
|
|
42
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
43
|
+
await wrapper.get('button').trigger('click')
|
|
44
|
+
await wrapper.findAll('[role="option"] button')[1].trigger('click')
|
|
45
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([['2']])
|
|
46
|
+
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not emit for a disabled row', async () => {
|
|
50
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
51
|
+
await wrapper.get('button').trigger('click')
|
|
52
|
+
await wrapper.findAll('[role="option"] button')[2].trigger('click')
|
|
53
|
+
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('marks the selected row with aria-selected and the active fill', async () => {
|
|
57
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: '1' } })
|
|
58
|
+
await wrapper.get('button').trigger('click')
|
|
59
|
+
const rows = wrapper.findAll('[role="option"]')
|
|
60
|
+
expect(rows[0].attributes('aria-selected')).toBe('true')
|
|
61
|
+
expect(rows[1].attributes('aria-selected')).toBe('false')
|
|
62
|
+
expect(rows[0].get('button').classes()).toContain('bg-emerald-500')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('replaces the default trigger skin when triggerClass is set', () => {
|
|
66
|
+
const wrapper = mount(BaseDropdown, {
|
|
67
|
+
props: { options, modelValue: '1', triggerClass: 'rounded border bg-red-100 text-red-700 px-1.5 py-0.5' },
|
|
68
|
+
})
|
|
69
|
+
const classes = wrapper.get('button').classes()
|
|
70
|
+
// Custom skin applied…
|
|
71
|
+
expect(classes).toContain('bg-red-100')
|
|
72
|
+
expect(classes).toContain('text-red-700')
|
|
73
|
+
// …and the default slate skin dropped (layout/focus classes remain).
|
|
74
|
+
expect(classes).not.toContain('border-slate-300')
|
|
75
|
+
expect(classes).not.toContain('bg-white')
|
|
76
|
+
expect(classes).toContain('cursor-pointer')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('sizes the chevron via chevronClass', () => {
|
|
80
|
+
const wrapper = mount(BaseDropdown, {
|
|
81
|
+
props: { options, modelValue: '1', chevronClass: 'w-3 h-3' },
|
|
82
|
+
})
|
|
83
|
+
const chevron = wrapper.get('button svg')
|
|
84
|
+
expect(chevron.classes()).toContain('w-3')
|
|
85
|
+
expect(chevron.classes()).not.toContain('w-5')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('tints the default skin by tone while keeping the size padding', () => {
|
|
89
|
+
const danger = mount(BaseDropdown, { props: { options, modelValue: '1', size: 'sm', tone: 'danger' } })
|
|
90
|
+
const cls = danger.get('button').classes()
|
|
91
|
+
expect(cls).toEqual(expect.arrayContaining(['border-red-300', 'bg-red-50', 'text-red-700', 'px-3', 'py-2']))
|
|
92
|
+
expect(cls).not.toContain('bg-white')
|
|
93
|
+
|
|
94
|
+
const success = mount(BaseDropdown, { props: { options, modelValue: '1', tone: 'success' } })
|
|
95
|
+
expect(success.get('button').classes()).toEqual(expect.arrayContaining(['bg-emerald-50', 'px-4']))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('defaults to the neutral slate skin', () => {
|
|
99
|
+
const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
|
|
100
|
+
expect(wrapper.get('button').classes()).toContain('bg-white')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -1,172 +1,188 @@
|
|
|
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
|
-
<!-- min-w-0 + truncate so long labels ellipsize inside narrow
|
|
29
|
-
(fixed-column) triggers instead of wrapping or widening them. -->
|
|
30
|
-
<span class="min-w-0 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
|
|
31
|
-
{{ selectedOption ? selectedOption.label : placeholder }}
|
|
32
|
-
</span>
|
|
33
|
-
<ChevronDownIcon
|
|
34
|
-
class="shrink-0 transition-transform"
|
|
35
|
-
:class="[chevronClass, open ? 'rotate-180' : '']"
|
|
36
|
-
aria-hidden="true"
|
|
37
|
-
/>
|
|
38
|
-
</button>
|
|
39
|
-
|
|
40
|
-
<ul
|
|
41
|
-
v-if="open"
|
|
42
|
-
role="listbox"
|
|
43
|
-
:aria-label="ariaLabel || placeholder"
|
|
44
|
-
class="absolute z-20 mt-1 w-full min-w-max max-w-xs 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"
|
|
45
|
-
>
|
|
46
|
-
<li
|
|
47
|
-
v-for="opt in options"
|
|
48
|
-
:key="String(opt.value)"
|
|
49
|
-
role="option"
|
|
50
|
-
:aria-selected="opt.value === modelValue"
|
|
51
|
-
:title="opt.title"
|
|
52
|
-
>
|
|
53
|
-
<button
|
|
54
|
-
type="button"
|
|
55
|
-
:disabled="opt.disabled"
|
|
56
|
-
:class="rowClass(opt)"
|
|
57
|
-
@click="select(opt)"
|
|
58
|
-
>
|
|
59
|
-
{{ opt.label }}
|
|
60
|
-
</button>
|
|
61
|
-
</li>
|
|
62
|
-
</ul>
|
|
63
|
-
</div>
|
|
64
|
-
</template>
|
|
65
|
-
|
|
66
|
-
<script setup lang="ts">
|
|
67
|
-
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
68
|
-
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
69
|
-
import { useEscapeKey } from '../composables/useEscapeKey'
|
|
70
|
-
import type { DropdownOption } from '../types/dropdown'
|
|
71
|
-
|
|
72
|
-
// NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
|
|
73
|
-
// emitted declarations, so consumers would see the erased constraint anyway.
|
|
74
|
-
// The update:modelValue payload is `any` so `v-model` on a union-typed ref
|
|
75
|
-
// type-checks; the actual payload is always one of the options' values.
|
|
76
|
-
interface Props {
|
|
77
|
-
options: readonly DropdownOption[]
|
|
78
|
-
modelValue?: string | number | null
|
|
79
|
-
/** Trigger text shown when nothing is selected. */
|
|
80
|
-
placeholder?: string
|
|
81
|
-
/** 'md' = px-4 py-2.5 (default, the app filter height); 'sm' = px-3 py-2. */
|
|
82
|
-
size?: 'sm' | 'md'
|
|
83
|
-
/** Full-width (w-full). Set false for an inline, content-width dropdown. */
|
|
84
|
-
block?: boolean
|
|
85
|
-
disabled?: boolean
|
|
86
|
-
/** Accessible name for the trigger/listbox when there is no visible label. */
|
|
87
|
-
ariaLabel?: string
|
|
88
|
-
/**
|
|
89
|
-
* Replaces the trigger's default slate skin (border/background/text/padding)
|
|
90
|
-
* with the given classes — layout, focus ring and disabled classes are kept.
|
|
91
|
-
* Lets a consumer render the trigger as e.g. a coloured status pill while
|
|
92
|
-
* keeping the shared menu behaviour. `size` is ignored when this is set.
|
|
93
|
-
*/
|
|
94
|
-
triggerClass?: string
|
|
95
|
-
/** Chevron sizing/colour classes; shrink for compact triggers. */
|
|
96
|
-
chevronClass?: string
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
'
|
|
133
|
-
'
|
|
134
|
-
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
<!-- min-w-0 + truncate so long labels ellipsize inside narrow
|
|
29
|
+
(fixed-column) triggers instead of wrapping or widening them. -->
|
|
30
|
+
<span class="min-w-0 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
|
|
31
|
+
{{ selectedOption ? selectedOption.label : placeholder }}
|
|
32
|
+
</span>
|
|
33
|
+
<ChevronDownIcon
|
|
34
|
+
class="shrink-0 transition-transform"
|
|
35
|
+
:class="[chevronClass, open ? 'rotate-180' : '']"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
/>
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
<ul
|
|
41
|
+
v-if="open"
|
|
42
|
+
role="listbox"
|
|
43
|
+
:aria-label="ariaLabel || placeholder"
|
|
44
|
+
class="absolute z-20 mt-1 w-full min-w-max max-w-xs 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"
|
|
45
|
+
>
|
|
46
|
+
<li
|
|
47
|
+
v-for="opt in options"
|
|
48
|
+
:key="String(opt.value)"
|
|
49
|
+
role="option"
|
|
50
|
+
:aria-selected="opt.value === modelValue"
|
|
51
|
+
:title="opt.title"
|
|
52
|
+
>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
:disabled="opt.disabled"
|
|
56
|
+
:class="rowClass(opt)"
|
|
57
|
+
@click="select(opt)"
|
|
58
|
+
>
|
|
59
|
+
{{ opt.label }}
|
|
60
|
+
</button>
|
|
61
|
+
</li>
|
|
62
|
+
</ul>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<script setup lang="ts">
|
|
67
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
68
|
+
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
69
|
+
import { useEscapeKey } from '../composables/useEscapeKey'
|
|
70
|
+
import type { DropdownOption } from '../types/dropdown'
|
|
71
|
+
|
|
72
|
+
// NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
|
|
73
|
+
// emitted declarations, so consumers would see the erased constraint anyway.
|
|
74
|
+
// The update:modelValue payload is `any` so `v-model` on a union-typed ref
|
|
75
|
+
// type-checks; the actual payload is always one of the options' values.
|
|
76
|
+
interface Props {
|
|
77
|
+
options: readonly DropdownOption[]
|
|
78
|
+
modelValue?: string | number | null
|
|
79
|
+
/** Trigger text shown when nothing is selected. */
|
|
80
|
+
placeholder?: string
|
|
81
|
+
/** 'md' = px-4 py-2.5 (default, the app filter height); 'sm' = px-3 py-2. */
|
|
82
|
+
size?: 'sm' | 'md'
|
|
83
|
+
/** Full-width (w-full). Set false for an inline, content-width dropdown. */
|
|
84
|
+
block?: boolean
|
|
85
|
+
disabled?: boolean
|
|
86
|
+
/** Accessible name for the trigger/listbox when there is no visible label. */
|
|
87
|
+
ariaLabel?: string
|
|
88
|
+
/**
|
|
89
|
+
* Replaces the trigger's default slate skin (border/background/text/padding)
|
|
90
|
+
* with the given classes — layout, focus ring and disabled classes are kept.
|
|
91
|
+
* Lets a consumer render the trigger as e.g. a coloured status pill while
|
|
92
|
+
* keeping the shared menu behaviour. `size` is ignored when this is set.
|
|
93
|
+
*/
|
|
94
|
+
triggerClass?: string
|
|
95
|
+
/** Chevron sizing/colour classes; shrink for compact triggers. */
|
|
96
|
+
chevronClass?: string
|
|
97
|
+
/**
|
|
98
|
+
* Colour of the default trigger skin, keeping the standard `size` padding:
|
|
99
|
+
* 'neutral' (slate, default), 'success' (emerald) or 'danger' (red). Use it
|
|
100
|
+
* for a status dropdown (e.g. Active / Paused) that should match the height
|
|
101
|
+
* of the plain dropdowns beside it. Ignored when `triggerClass` is set.
|
|
102
|
+
*/
|
|
103
|
+
tone?: 'neutral' | 'success' | 'danger'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
107
|
+
modelValue: null,
|
|
108
|
+
placeholder: 'Select',
|
|
109
|
+
size: 'md',
|
|
110
|
+
block: true,
|
|
111
|
+
disabled: false,
|
|
112
|
+
ariaLabel: '',
|
|
113
|
+
triggerClass: '',
|
|
114
|
+
chevronClass: 'w-5 h-5 text-slate-500 dark:text-slate-400',
|
|
115
|
+
tone: 'neutral',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const emit = defineEmits<{
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
|
+
(e: 'update:modelValue', value: any): void
|
|
121
|
+
}>()
|
|
122
|
+
|
|
123
|
+
const open = ref(false)
|
|
124
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
125
|
+
|
|
126
|
+
const selectedOption = computed(() =>
|
|
127
|
+
props.options.find((o) => o.value === props.modelValue) ?? null,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const triggerClass = computed(() => {
|
|
131
|
+
const layout =
|
|
132
|
+
'flex items-center justify-between gap-2 text-left transition-colors ' +
|
|
133
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
|
|
134
|
+
'cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed '
|
|
135
|
+
if (props.triggerClass) return layout + props.triggerClass
|
|
136
|
+
const padding = props.size === 'sm' ? 'px-3 py-2' : 'px-4 py-2.5'
|
|
137
|
+
return layout + 'rounded-lg border text-sm ' + TONE_SKIN[props.tone] + ' ' + padding
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Full literal class strings (no concatenated fragments) so Tailwind's
|
|
141
|
+
// scanner keeps every one of them in consumer builds.
|
|
142
|
+
const TONE_SKIN: Record<NonNullable<Props['tone']>, string> = {
|
|
143
|
+
neutral:
|
|
144
|
+
'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
|
|
145
|
+
'hover:bg-slate-50 dark:hover:bg-slate-800',
|
|
146
|
+
success:
|
|
147
|
+
'font-medium border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 ' +
|
|
148
|
+
'hover:bg-emerald-100 dark:hover:bg-emerald-900/50',
|
|
149
|
+
danger:
|
|
150
|
+
'font-medium border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 ' +
|
|
151
|
+
'hover:bg-red-100 dark:hover:bg-red-900/50',
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function rowClass(opt: DropdownOption): string {
|
|
155
|
+
const selected = opt.value === props.modelValue
|
|
156
|
+
const base =
|
|
157
|
+
'block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
158
|
+
const fill = selected
|
|
159
|
+
? 'bg-emerald-500 text-white'
|
|
160
|
+
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
|
|
161
|
+
return `${base} ${fill}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toggle() {
|
|
165
|
+
if (props.disabled) return
|
|
166
|
+
open.value = !open.value
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function select(opt: DropdownOption) {
|
|
170
|
+
if (opt.disabled) return
|
|
171
|
+
emit('update:modelValue', opt.value)
|
|
172
|
+
open.value = false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
useEscapeKey(() => {
|
|
176
|
+
open.value = false
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
function onDocumentClick(e: MouseEvent) {
|
|
180
|
+
if (!open.value) return
|
|
181
|
+
if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
|
|
182
|
+
open.value = false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
onMounted(() => document.addEventListener('click', onDocumentClick))
|
|
187
|
+
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
|
188
|
+
</script>
|