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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -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
- const props = withDefaults(defineProps<Props>(), {
100
- modelValue: null,
101
- placeholder: 'Select',
102
- size: 'md',
103
- block: true,
104
- disabled: false,
105
- ariaLabel: '',
106
- triggerClass: '',
107
- chevronClass: 'w-5 h-5 text-slate-500 dark:text-slate-400',
108
- })
109
-
110
- const emit = defineEmits<{
111
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
- (e: 'update:modelValue', value: any): void
113
- }>()
114
-
115
- const open = ref(false)
116
- const rootEl = ref<HTMLElement | null>(null)
117
-
118
- const selectedOption = computed(() =>
119
- props.options.find((o) => o.value === props.modelValue) ?? null,
120
- )
121
-
122
- const triggerClass = computed(() => {
123
- const layout =
124
- 'flex items-center justify-between gap-2 text-left transition-colors ' +
125
- 'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
126
- 'cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed '
127
- if (props.triggerClass) return layout + props.triggerClass
128
- const padding = props.size === 'sm' ? 'px-3 py-2' : 'px-4 py-2.5'
129
- return (
130
- layout +
131
- 'rounded-lg border text-sm ' +
132
- 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
133
- 'hover:bg-slate-50 dark:hover:bg-slate-800 ' +
134
- padding
135
- )
136
- })
137
-
138
- function rowClass(opt: DropdownOption): string {
139
- const selected = opt.value === props.modelValue
140
- const base =
141
- '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'
142
- const fill = selected
143
- ? 'bg-emerald-500 text-white'
144
- : 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
145
- return `${base} ${fill}`
146
- }
147
-
148
- function toggle() {
149
- if (props.disabled) return
150
- open.value = !open.value
151
- }
152
-
153
- function select(opt: DropdownOption) {
154
- if (opt.disabled) return
155
- emit('update:modelValue', opt.value)
156
- open.value = false
157
- }
158
-
159
- useEscapeKey(() => {
160
- open.value = false
161
- })
162
-
163
- function onDocumentClick(e: MouseEvent) {
164
- if (!open.value) return
165
- if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
166
- open.value = false
167
- }
168
- }
169
-
170
- onMounted(() => document.addEventListener('click', onDocumentClick))
171
- onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
172
- </script>
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>