mgv-backoffice 1.37.0 → 1.40.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 +147 -4
- package/dist/components/BaseAuthLayout.vue.d.ts +27 -0
- package/dist/components/BaseBooleanBadge.vue.d.ts +12 -0
- package/dist/components/BaseCard.vue.d.ts +33 -0
- package/dist/components/BaseDetailList.vue.d.ts +13 -0
- package/dist/components/BaseField.vue.d.ts +38 -0
- package/dist/components/BasePageHeader.vue.d.ts +3 -0
- package/dist/components/BaseSearchSelect.vue.d.ts +41 -0
- package/dist/components/BaseStatCard.vue.d.ts +33 -0
- package/dist/components/BaseTable.vue.d.ts +3 -0
- package/dist/components/BaseToggle.vue.d.ts +33 -0
- package/dist/index.d.ts +9 -0
- package/dist/types/detail.d.ts +10 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1388 -881
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseAuthLayout.test.ts +48 -0
- package/src/components/BaseAuthLayout.vue +50 -0
- package/src/components/BaseBooleanBadge.vue +33 -0
- package/src/components/BaseBreadcrumb.vue +68 -68
- package/src/components/BaseButton.vue +1 -1
- package/src/components/BaseCard.test.ts +55 -0
- package/src/components/BaseCard.vue +48 -0
- package/src/components/BaseDetailList.test.ts +36 -0
- package/src/components/BaseDetailList.vue +43 -0
- package/src/components/BaseField.vue +47 -0
- package/src/components/BaseInput.test.ts +1 -1
- package/src/components/BaseInput.vue +46 -46
- package/src/components/BaseLogo.test.ts +26 -0
- package/src/components/BaseLogo.vue +7 -1
- package/src/components/BasePageHeader.vue +13 -2
- package/src/components/BaseSearchSelect.test.ts +117 -0
- package/src/components/BaseSearchSelect.vue +318 -0
- package/src/components/BaseSelect.test.ts +3 -3
- package/src/components/BaseSelect.vue +62 -46
- package/src/components/BaseStatCard.test.ts +40 -0
- package/src/components/BaseStatCard.vue +103 -0
- package/src/components/BaseTable.test.ts +69 -55
- package/src/components/BaseTable.vue +62 -50
- package/src/components/BaseToggle.test.ts +39 -0
- package/src/components/BaseToggle.vue +73 -0
- package/src/components/Pagination.test.ts +93 -85
- package/src/components/Pagination.vue +87 -82
- package/src/index.ts +9 -0
- package/src/style.css +58 -58
- package/src/types/detail.ts +10 -0
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<router-link to="/" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
|
|
3
|
-
<svg :height="computePx()" :width="computePx()" viewBox="33.6 130.6 499.7 242.9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset=".085" stop-color="#469e43"/><stop offset="1" stop-color="#132d13"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="64.863" x2="64.863"
|
|
3
|
+
<svg :height="computePx()" :width="computePx()" viewBox="33.6 130.6 499.7 242.9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient :id="`${uid}-a`"><stop offset=".085" stop-color="#469e43"/><stop offset="1" stop-color="#132d13"/></linearGradient><linearGradient :id="`${uid}-b`" gradientUnits="userSpaceOnUse" x1="64.863" x2="64.863" :href="`#${uid}-a`" y1="288.291" y2="369.4"/><linearGradient :id="`${uid}-c`" gradientUnits="userSpaceOnUse" x1="138.367" x2="138.367" :href="`#${uid}-a`" y1="308.454" y2="370.601"/><linearGradient :id="`${uid}-d`" gradientUnits="userSpaceOnUse" x1="195.475" x2="195.475" :href="`#${uid}-a`" y1="288.291" y2="369.85"/><linearGradient :id="`${uid}-e`" gradientUnits="userSpaceOnUse" x1="249.634" x2="249.634" :href="`#${uid}-a`" y1="307.312" y2="369.511"/><linearGradient :id="`${uid}-f`" gradientUnits="userSpaceOnUse" x1="302.884" x2="302.884" :href="`#${uid}-a`" y1="288.291" y2="367.857"/><linearGradient :id="`${uid}-g`" gradientUnits="userSpaceOnUse" x1="354.118" x2="354.118" :href="`#${uid}-a`" y1="307.312" y2="369.511"/><linearGradient :id="`${uid}-h`" gradientUnits="userSpaceOnUse" x1="428.02" x2="428.02" :href="`#${uid}-a`" y1="306.156" y2="367.742"/><linearGradient :id="`${uid}-i`" gradientUnits="userSpaceOnUse" x1="502.066" x2="502.066" :href="`#${uid}-a`" y1="288.291" y2="369.4"/><linearGradient :id="`${uid}-j`" gradientUnits="userSpaceOnUse" x1="283.264" x2="283.264" :href="`#${uid}-a`" y1="131.629" y2="232.559"/><linearGradient :id="`${uid}-k`" gradientUnits="userSpaceOnUse" x1="283.46" x2="283.46" y1="202.275" y2="183.785"><stop offset="0" stop-color="#969799" stop-opacity="0"/><stop offset="1" stop-color="#808183"/></linearGradient><path d="m65 307.2c-6.8 0-13.1 2.2-18.2 5.9v-24.8h-13.2v81.1h31.4c17.2 0 31.1-13.9 31.1-31.1s-13.9-31.1-31.1-31.1zm0 49.3h-18.2v-18.2c0-10.1 8.2-18.2 18.2-18.2 10.1 0 18.2 8.2 18.2 18.2.1 10.1-8.1 18.2-18.2 18.2z" :fill="`url(#${uid}-b)`"/><path d="m168.8 343.9h-46.7c1.4 7.1 6.6 12 14.1 12.7 6.4.6 12.9.1 19.9.1v11.9c-15.2 4.9-33.3.5-42.1-10.4-3.8-4.7-17.3-34.7 11.5-47.4 39-11.4 45.3 22.4 43.3 33.1zm-14.2-12.7c-1.7-8.1-8.8-12.9-17.9-12.3-7.6.5-14 5.8-14.5 12.3z" :fill="`url(#${uid}-c)`"/><path d="m195.5 320.2c0 10.5-.2 20 .1 29.6.2 5.8 4.4 8.4 13.2 8.6v10.5c-13.8 3.9-26.2-4.5-26.4-18-.2-18.7-.1-43.9-.1-62.6h14.1l-.3 18.5 12.7-.1v13.4h-13.3z" :fill="`url(#${uid}-d)`"/><path d="m249.6 307.3c-17.2 0-31.1 13.9-31.1 31.1s13.9 31.1 31.1 31.1h31.1v-31.1c0-17.2-13.9-31.1-31.1-31.1zm18.6 49.7h-18.5c-10.2 0-18.5-8.3-18.5-18.5s8.3-18.5 18.5-18.5 18.5 8.3 18.5 18.5z" :fill="`url(#${uid}-e)`"/><path d="m296.4 288.3h13v79.6h-13z" :fill="`url(#${uid}-f)`"/><path d="m354.1 307.3c-17.2 0-31.1 13.9-31.1 31.1s13.9 31.1 31.1 31.1h31.1v-31.1c0-17.2-13.9-31.1-31.1-31.1zm18.6 49.7h-18.5c-10.2 0-18.5-8.3-18.5-18.5s8.3-18.5 18.5-18.5 18.5 8.3 18.5 18.5z" :fill="`url(#${uid}-g)`"/><path d="m442.2 367.7c0-12.3.2-23.6 0-34.9-.2-3.4-3.6-13.8-14.1-13.4-10.3-.3-14.4 10-14.3 12.7.1 10.2.5 35.1.5 35.1l-13.6.1s-.3-25.7.2-36.2c.6-14.1 12.7-25 27.5-25 14.7 0 26.2 10.4 26.7 24.9.4 10.4.5 36.3.5 36.3z" :fill="`url(#${uid}-h)`"/><path d="m501.9 307.2c6.8 0 13.1 2.2 18.2 5.9v-24.8h13.2v81.1h-31.4c-17.2 0-31.1-13.9-31.1-31.1s13.9-31.1 31.1-31.1zm0 49.3h18.2v-18.2c0-10.1-8.2-18.2-18.2-18.2-10.1 0-18.2 8.2-18.2 18.2 0 10.1 8.1 18.2 18.2 18.2z" :fill="`url(#${uid}-i)`"/><path d="m309.2 180.6h-24v-24c0-12.6 10.2-24.1 22.8-25 14.8-1 27.1 11.3 26.1 26.1-.8 12.7-12.3 22.9-24.9 22.9zm-50.7-48.9c-14.8-1-27.1 11.3-26.1 26.1.8 12.6 12.4 22.8 25 22.8h23.9v-24c0-12.6-10.3-24.1-22.8-24.9zm50.7 51.9h-24v23.9c0 12.6 10.2 24.1 22.8 25 14.8 1 27.1-11.3 26.1-26.1-.8-12.6-12.3-22.8-24.9-22.8zm-52.4 0c-14 0-25.3 11.8-24.4 26 .8 12.2 10.7 22.1 22.9 22.9 14.2.9 26-10.4 26-24.4v-24.5z" :fill="`url(#${uid}-j)`"/><path d="m281.7 183.8h3.6v18.5h-3.6z" :fill="`url(#${uid}-k)`"/></svg>
|
|
4
4
|
</router-link>
|
|
5
5
|
</template>
|
|
6
6
|
|
|
7
7
|
<script lang="ts" setup>
|
|
8
|
+
import { useId } from "vue";
|
|
8
9
|
import { BaseLogoEnum } from "../enums/BaseLogoEnum";
|
|
9
10
|
|
|
11
|
+
// Per-instance prefix for the SVG gradient ids. With fixed ids, every logo on
|
|
12
|
+
// the page pointed at the FIRST logo's gradients, so when that one was hidden
|
|
13
|
+
// (e.g. the sidebar's mobile top bar on desktop) the visible logos lost their fill.
|
|
14
|
+
const uid = `logo-${useId()}`;
|
|
15
|
+
|
|
10
16
|
const props = defineProps({
|
|
11
17
|
size: {
|
|
12
18
|
type: String,
|
|
@@ -47,10 +47,18 @@
|
|
|
47
47
|
<slot name="icon" :icon-class="iconTextClass" />
|
|
48
48
|
</div>
|
|
49
49
|
<div>
|
|
50
|
-
<h1
|
|
50
|
+
<h1
|
|
51
|
+
class="text-2xl font-bold"
|
|
52
|
+
:class="[t.primaryTextSoft, accent ? 'relative pl-3.5 tracking-tight' : '']"
|
|
53
|
+
>
|
|
54
|
+
<span
|
|
55
|
+
v-if="accent"
|
|
56
|
+
class="absolute left-0 top-[0.14em] h-[1.1em] w-1 rounded-full bg-gradient-to-b from-emerald-400 to-emerald-600"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
/>
|
|
51
59
|
{{ title }}
|
|
52
60
|
</h1>
|
|
53
|
-
<p v-if="subtitle || $slots.subtitle" class="text-sm" :class="t.dimTextAlt">
|
|
61
|
+
<p v-if="subtitle || $slots.subtitle" class="max-w-[72ch] text-sm" :class="t.dimTextAlt">
|
|
54
62
|
<slot name="subtitle">{{ subtitle }}</slot>
|
|
55
63
|
</p>
|
|
56
64
|
</div>
|
|
@@ -77,6 +85,8 @@ const props = withDefaults(
|
|
|
77
85
|
maxWidthClass?: string
|
|
78
86
|
align?: 'center' | 'end'
|
|
79
87
|
marginClass?: string
|
|
88
|
+
/** Brand-green gauge bar in front of the title (icon-less headers). */
|
|
89
|
+
accent?: boolean
|
|
80
90
|
}>(),
|
|
81
91
|
{
|
|
82
92
|
subtitle: '',
|
|
@@ -84,6 +94,7 @@ const props = withDefaults(
|
|
|
84
94
|
maxWidthClass: 'max-w-4xl',
|
|
85
95
|
align: 'center',
|
|
86
96
|
marginClass: 'mb-8',
|
|
97
|
+
accent: false,
|
|
87
98
|
},
|
|
88
99
|
)
|
|
89
100
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
|
+
import { mount, type VueWrapper } from '@vue/test-utils'
|
|
3
|
+
import BaseSearchSelect from './BaseSearchSelect.vue'
|
|
4
|
+
import type { DropdownOption } from '../types/dropdown'
|
|
5
|
+
|
|
6
|
+
const options: DropdownOption[] = [
|
|
7
|
+
{ value: 1, label: 'Alice' },
|
|
8
|
+
{ value: 2, label: 'Bob' },
|
|
9
|
+
{ value: 3, label: 'Carol', disabled: true },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
let wrapper: VueWrapper | undefined
|
|
13
|
+
afterEach(() => wrapper?.unmount())
|
|
14
|
+
|
|
15
|
+
function mountIt(props: Record<string, unknown> = {}) {
|
|
16
|
+
wrapper = mount(BaseSearchSelect, { props: { options, ...props }, attachTo: document.body })
|
|
17
|
+
return wrapper
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('BaseSearchSelect', () => {
|
|
21
|
+
it('shows the placeholder, or the selected label in single mode', async () => {
|
|
22
|
+
const w = mountIt({ placeholder: 'Select User' })
|
|
23
|
+
expect(w.find('input').attributes('placeholder')).toBe('Select User')
|
|
24
|
+
await w.setProps({ modelValue: 2 })
|
|
25
|
+
expect(w.find('input').attributes('placeholder')).toBe('Bob')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('opens on focus and filters options by the typed text', async () => {
|
|
29
|
+
const w = mountIt()
|
|
30
|
+
const input = w.find('input')
|
|
31
|
+
await input.trigger('focus')
|
|
32
|
+
expect(w.findAll('[role=option]')).toHaveLength(3)
|
|
33
|
+
await input.setValue('ali')
|
|
34
|
+
expect(w.findAll('[role=option]').map((o) => o.text())).toEqual(['Alice'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('shows a no-results row when nothing matches', async () => {
|
|
38
|
+
const w = mountIt({ noResultsText: 'Nothing.' })
|
|
39
|
+
await w.find('input').trigger('focus')
|
|
40
|
+
await w.find('input').setValue('zzz')
|
|
41
|
+
expect(w.find('[role=listbox]').text()).toBe('Nothing.')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('emits the picked value and closes in single mode', async () => {
|
|
45
|
+
const w = mountIt()
|
|
46
|
+
await w.find('input').trigger('focus')
|
|
47
|
+
await w.findAll('[role=option]')[1].trigger('click')
|
|
48
|
+
expect(w.emitted('update:modelValue')).toEqual([[2]])
|
|
49
|
+
expect(w.find('[role=listbox]').exists()).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('ignores disabled options', async () => {
|
|
53
|
+
const w = mountIt()
|
|
54
|
+
await w.find('input').trigger('focus')
|
|
55
|
+
await w.findAll('[role=option]')[2].trigger('click')
|
|
56
|
+
expect(w.emitted('update:modelValue')).toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('toggles values and stays open in multiple mode', async () => {
|
|
60
|
+
const w = mountIt({ multiple: true, modelValue: [1] })
|
|
61
|
+
await w.find('input').trigger('focus')
|
|
62
|
+
await w.findAll('[role=option]')[1].trigger('click')
|
|
63
|
+
expect(w.emitted('update:modelValue')?.[0]).toEqual([[1, 2]])
|
|
64
|
+
await w.findAll('[role=option]')[0].trigger('click')
|
|
65
|
+
expect(w.emitted('update:modelValue')?.[1]).toEqual([[]])
|
|
66
|
+
expect(w.find('[role=listbox]').exists()).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders chips for selected values and removes one on ×', async () => {
|
|
70
|
+
const w = mountIt({ multiple: true, modelValue: [1, 2] })
|
|
71
|
+
const remove = w.find('[aria-label="Remove Alice"]')
|
|
72
|
+
expect(remove.exists()).toBe(true)
|
|
73
|
+
await remove.trigger('click')
|
|
74
|
+
expect(w.emitted('update:modelValue')).toEqual([[[2]]])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('removes the last chip on Backspace with an empty search', async () => {
|
|
78
|
+
const w = mountIt({ multiple: true, modelValue: [1, 2] })
|
|
79
|
+
await w.find('input').trigger('keydown', { key: 'Backspace' })
|
|
80
|
+
expect(w.emitted('update:modelValue')).toEqual([[[1]]])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('picks with the keyboard, skipping disabled rows', async () => {
|
|
84
|
+
const w = mountIt()
|
|
85
|
+
const input = w.find('input')
|
|
86
|
+
await input.trigger('keydown', { key: 'ArrowDown' }) // opens, Alice active
|
|
87
|
+
await input.trigger('keydown', { key: 'ArrowDown' }) // Bob
|
|
88
|
+
await input.trigger('keydown', { key: 'ArrowDown' }) // Carol disabled → wraps to Alice
|
|
89
|
+
await input.trigger('keydown', { key: 'Enter' })
|
|
90
|
+
expect(w.emitted('update:modelValue')).toEqual([[1]])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('clears to null (single) or [] (multiple)', async () => {
|
|
94
|
+
const single = mountIt({ modelValue: 1 })
|
|
95
|
+
await single.find('[aria-label="Clear selection"]').trigger('click')
|
|
96
|
+
expect(single.emitted('update:modelValue')).toEqual([[null]])
|
|
97
|
+
single.unmount()
|
|
98
|
+
|
|
99
|
+
const multi = mountIt({ multiple: true, modelValue: [1] })
|
|
100
|
+
await multi.find('[aria-label="Clear selection"]').trigger('click')
|
|
101
|
+
expect(multi.emitted('update:modelValue')).toEqual([[[]]])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('closes on Escape and on an outside click', async () => {
|
|
105
|
+
const w = mountIt()
|
|
106
|
+
await w.find('input').trigger('focus')
|
|
107
|
+
await w.find('input').trigger('keydown', { key: 'Escape' })
|
|
108
|
+
expect(w.find('[role=listbox]').exists()).toBe(false)
|
|
109
|
+
|
|
110
|
+
await w.find('input').trigger('blur')
|
|
111
|
+
await w.find('input').trigger('focus')
|
|
112
|
+
expect(w.find('[role=listbox]').exists()).toBe(true)
|
|
113
|
+
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
114
|
+
await w.vm.$nextTick()
|
|
115
|
+
expect(w.find('[role=listbox]').exists()).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Searchable select ("combobox"). Type to filter the options, pick with the
|
|
4
|
+
mouse or ArrowUp/ArrowDown + Enter. `multiple` turns it into a tag picker:
|
|
5
|
+
picked options show as removable chips and the menu stays open so several
|
|
6
|
+
can be picked in a row (Backspace on an empty search removes the last one).
|
|
7
|
+
|
|
8
|
+
Options are data ({ value, label, title?, disabled? } — the same shape as
|
|
9
|
+
BaseDropdown), and the field skin matches BaseInput/BaseSelect so it can sit
|
|
10
|
+
in the same filter row. Escape and an outside click close the menu.
|
|
11
|
+
-->
|
|
12
|
+
<div ref="rootEl" :class="block ? 'relative w-full' : 'relative inline-block min-w-48'">
|
|
13
|
+
<div
|
|
14
|
+
class="flex min-h-[38px] items-center gap-1.5 rounded-lg border bg-white px-3 py-1.5 text-sm transition-colors dark:bg-slate-900"
|
|
15
|
+
:class="[
|
|
16
|
+
open
|
|
17
|
+
? 'border-emerald-500 ring-2 ring-emerald-500/30'
|
|
18
|
+
: 'border-slate-300 hover:border-slate-400 dark:border-slate-600 dark:hover:border-slate-500',
|
|
19
|
+
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-text',
|
|
20
|
+
]"
|
|
21
|
+
@mousedown="onControlMouseDown"
|
|
22
|
+
>
|
|
23
|
+
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1">
|
|
24
|
+
<span
|
|
25
|
+
v-for="opt in selectedOptions"
|
|
26
|
+
v-show="multiple"
|
|
27
|
+
:key="String(opt.value)"
|
|
28
|
+
class="inline-flex max-w-full items-center gap-1 rounded-md bg-emerald-50 py-0.5 pl-2 pr-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
|
|
29
|
+
>
|
|
30
|
+
<span class="truncate">{{ opt.label }}</span>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class="rounded p-0.5 hover:bg-emerald-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 dark:hover:bg-emerald-500/25"
|
|
34
|
+
:aria-label="`Remove ${opt.label}`"
|
|
35
|
+
:disabled="disabled"
|
|
36
|
+
@mousedown.stop.prevent
|
|
37
|
+
@click.stop="remove(opt)"
|
|
38
|
+
>
|
|
39
|
+
<XMarkIcon class="h-3 w-3" aria-hidden="true" />
|
|
40
|
+
</button>
|
|
41
|
+
</span>
|
|
42
|
+
<input
|
|
43
|
+
ref="inputEl"
|
|
44
|
+
v-model="query"
|
|
45
|
+
type="text"
|
|
46
|
+
role="combobox"
|
|
47
|
+
autocomplete="off"
|
|
48
|
+
:disabled="disabled"
|
|
49
|
+
:readonly="!searchable"
|
|
50
|
+
:aria-label="ariaLabel || placeholder"
|
|
51
|
+
aria-autocomplete="list"
|
|
52
|
+
:aria-expanded="open"
|
|
53
|
+
:aria-controls="listId"
|
|
54
|
+
:aria-activedescendant="open && activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined"
|
|
55
|
+
:placeholder="inputPlaceholder"
|
|
56
|
+
class="min-w-16 flex-1 border-0 bg-transparent p-0 py-0.5 text-sm text-slate-900 outline-none focus:ring-0 dark:text-slate-100 disabled:cursor-not-allowed"
|
|
57
|
+
:class="hasSingleValue
|
|
58
|
+
? 'placeholder:text-slate-900 dark:placeholder:text-slate-100'
|
|
59
|
+
: 'placeholder:text-slate-400 dark:placeholder:text-slate-500'"
|
|
60
|
+
@focus="openMenu"
|
|
61
|
+
@input="openMenu"
|
|
62
|
+
@keydown="onKeydown"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<button
|
|
66
|
+
v-if="clearable && hasValue && !disabled"
|
|
67
|
+
type="button"
|
|
68
|
+
class="shrink-0 rounded p-0.5 text-slate-400 hover:text-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 dark:hover:text-slate-200"
|
|
69
|
+
aria-label="Clear selection"
|
|
70
|
+
@mousedown.stop.prevent
|
|
71
|
+
@click.stop="clear"
|
|
72
|
+
>
|
|
73
|
+
<XMarkIcon class="h-4 w-4" aria-hidden="true" />
|
|
74
|
+
</button>
|
|
75
|
+
<ChevronDownIcon
|
|
76
|
+
class="h-4 w-4 shrink-0 text-slate-500 transition-transform dark:text-slate-400"
|
|
77
|
+
:class="open ? 'rotate-180' : ''"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<ul
|
|
83
|
+
v-if="open"
|
|
84
|
+
:id="listId"
|
|
85
|
+
role="listbox"
|
|
86
|
+
:aria-multiselectable="multiple || undefined"
|
|
87
|
+
class="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-900"
|
|
88
|
+
>
|
|
89
|
+
<li
|
|
90
|
+
v-for="(opt, i) in filtered"
|
|
91
|
+
:id="`${listId}-${i}`"
|
|
92
|
+
:key="String(opt.value)"
|
|
93
|
+
role="option"
|
|
94
|
+
:aria-selected="isSelected(opt)"
|
|
95
|
+
:aria-disabled="opt.disabled || undefined"
|
|
96
|
+
:title="opt.title"
|
|
97
|
+
class="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left text-sm"
|
|
98
|
+
:class="rowClass(opt, i)"
|
|
99
|
+
@mousedown.prevent
|
|
100
|
+
@mouseenter="activeIndex = i"
|
|
101
|
+
@click="pick(opt)"
|
|
102
|
+
>
|
|
103
|
+
<span class="truncate">{{ opt.label }}</span>
|
|
104
|
+
<CheckIcon v-if="isSelected(opt)" class="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
105
|
+
</li>
|
|
106
|
+
<li v-if="filtered.length === 0" class="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
|
107
|
+
{{ options.length === 0 ? emptyText : noResultsText }}
|
|
108
|
+
</li>
|
|
109
|
+
</ul>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<script setup lang="ts">
|
|
114
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
|
115
|
+
import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/vue/24/outline'
|
|
116
|
+
import type { DropdownOption } from '../types/dropdown'
|
|
117
|
+
|
|
118
|
+
type Value = string | number
|
|
119
|
+
|
|
120
|
+
interface Props {
|
|
121
|
+
options: readonly DropdownOption[]
|
|
122
|
+
/** Selected value (single) or values (`multiple`). */
|
|
123
|
+
modelValue?: Value | null | readonly Value[]
|
|
124
|
+
/** Pick several options, shown as removable chips. */
|
|
125
|
+
multiple?: boolean
|
|
126
|
+
placeholder?: string
|
|
127
|
+
/** Allow typing to filter. False makes it a plain (non-typing) picker. */
|
|
128
|
+
searchable?: boolean
|
|
129
|
+
/** Show the clear (×) button while something is selected. */
|
|
130
|
+
clearable?: boolean
|
|
131
|
+
/** Full-width (w-full). Set false for an inline field. */
|
|
132
|
+
block?: boolean
|
|
133
|
+
disabled?: boolean
|
|
134
|
+
/** Accessible name when there is no visible label. */
|
|
135
|
+
ariaLabel?: string
|
|
136
|
+
/** Shown when the search matches nothing. */
|
|
137
|
+
noResultsText?: string
|
|
138
|
+
/** Shown when there are no options at all. */
|
|
139
|
+
emptyText?: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
143
|
+
modelValue: null,
|
|
144
|
+
multiple: false,
|
|
145
|
+
placeholder: 'Select',
|
|
146
|
+
searchable: true,
|
|
147
|
+
clearable: true,
|
|
148
|
+
block: true,
|
|
149
|
+
disabled: false,
|
|
150
|
+
ariaLabel: '',
|
|
151
|
+
noResultsText: 'No matches.',
|
|
152
|
+
emptyText: 'No options.',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const emit = defineEmits<{
|
|
156
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
157
|
+
(e: 'update:modelValue', value: any): void
|
|
158
|
+
}>()
|
|
159
|
+
|
|
160
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
161
|
+
const inputEl = ref<HTMLInputElement | null>(null)
|
|
162
|
+
const open = ref(false)
|
|
163
|
+
const query = ref('')
|
|
164
|
+
const activeIndex = ref(-1)
|
|
165
|
+
const listId = `search-select-${useId()}`
|
|
166
|
+
|
|
167
|
+
const selectedValues = computed<Value[]>(() => {
|
|
168
|
+
const v = props.modelValue
|
|
169
|
+
if (Array.isArray(v)) return [...v]
|
|
170
|
+
return v === null || v === undefined || v === '' ? [] : [v as Value]
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const selectedOptions = computed(() =>
|
|
174
|
+
selectedValues.value
|
|
175
|
+
.map((v) => props.options.find((o) => o.value === v))
|
|
176
|
+
.filter((o): o is DropdownOption => !!o),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const hasValue = computed(() => selectedValues.value.length > 0)
|
|
180
|
+
const hasSingleValue = computed(() => !props.multiple && selectedOptions.value.length > 0)
|
|
181
|
+
|
|
182
|
+
// Single mode shows the picked label as the input's placeholder, so typing
|
|
183
|
+
// starts a fresh search while the current choice stays visible until then.
|
|
184
|
+
const inputPlaceholder = computed(() => {
|
|
185
|
+
if (hasSingleValue.value) return selectedOptions.value[0].label
|
|
186
|
+
if (props.multiple && hasValue.value) return ''
|
|
187
|
+
return props.placeholder
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const filtered = computed(() => {
|
|
191
|
+
const q = query.value.trim().toLowerCase()
|
|
192
|
+
return q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : [...props.options]
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
watch(filtered, (list) => {
|
|
196
|
+
activeIndex.value = list.findIndex((o) => !o.disabled)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
function isSelected(opt: DropdownOption): boolean {
|
|
200
|
+
return selectedValues.value.includes(opt.value)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function rowClass(opt: DropdownOption, i: number): string {
|
|
204
|
+
if (opt.disabled) return 'cursor-not-allowed text-slate-400 dark:text-slate-600'
|
|
205
|
+
if (isSelected(opt)) {
|
|
206
|
+
return i === activeIndex.value
|
|
207
|
+
? 'bg-emerald-600 text-white'
|
|
208
|
+
: 'bg-emerald-500 text-white'
|
|
209
|
+
}
|
|
210
|
+
return i === activeIndex.value
|
|
211
|
+
? 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
|
|
212
|
+
: 'text-slate-700 dark:text-slate-200'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function openMenu() {
|
|
216
|
+
if (props.disabled || open.value) return
|
|
217
|
+
open.value = true
|
|
218
|
+
activeIndex.value = filtered.value.findIndex((o) => !o.disabled)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function closeMenu() {
|
|
222
|
+
open.value = false
|
|
223
|
+
query.value = ''
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onControlMouseDown(e: MouseEvent) {
|
|
227
|
+
if (props.disabled) return
|
|
228
|
+
// Keep focus in the input; toggle the menu when clicking the chrome.
|
|
229
|
+
if (e.target !== inputEl.value) e.preventDefault()
|
|
230
|
+
if (open.value && e.target !== inputEl.value) {
|
|
231
|
+
closeMenu()
|
|
232
|
+
} else {
|
|
233
|
+
inputEl.value?.focus()
|
|
234
|
+
openMenu()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pick(opt: DropdownOption) {
|
|
239
|
+
if (opt.disabled) return
|
|
240
|
+
if (props.multiple) {
|
|
241
|
+
const next = isSelected(opt)
|
|
242
|
+
? selectedValues.value.filter((v) => v !== opt.value)
|
|
243
|
+
: [...selectedValues.value, opt.value]
|
|
244
|
+
emit('update:modelValue', next)
|
|
245
|
+
query.value = ''
|
|
246
|
+
nextTick(() => inputEl.value?.focus())
|
|
247
|
+
} else {
|
|
248
|
+
emit('update:modelValue', opt.value)
|
|
249
|
+
closeMenu()
|
|
250
|
+
inputEl.value?.blur()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function remove(opt: DropdownOption) {
|
|
255
|
+
emit('update:modelValue', selectedValues.value.filter((v) => v !== opt.value))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function clear() {
|
|
259
|
+
emit('update:modelValue', props.multiple ? [] : null)
|
|
260
|
+
query.value = ''
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function move(step: 1 | -1) {
|
|
264
|
+
const list = filtered.value
|
|
265
|
+
if (list.length === 0) return
|
|
266
|
+
let i = activeIndex.value
|
|
267
|
+
for (let n = 0; n < list.length; n++) {
|
|
268
|
+
i = (i + step + list.length) % list.length
|
|
269
|
+
if (!list[i].disabled) break
|
|
270
|
+
}
|
|
271
|
+
activeIndex.value = i
|
|
272
|
+
nextTick(() => document.getElementById(`${listId}-${i}`)?.scrollIntoView?.({ block: 'nearest' }))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function onKeydown(e: KeyboardEvent) {
|
|
276
|
+
switch (e.key) {
|
|
277
|
+
case 'ArrowDown':
|
|
278
|
+
e.preventDefault()
|
|
279
|
+
if (!open.value) openMenu()
|
|
280
|
+
else move(1)
|
|
281
|
+
break
|
|
282
|
+
case 'ArrowUp':
|
|
283
|
+
e.preventDefault()
|
|
284
|
+
if (!open.value) openMenu()
|
|
285
|
+
else move(-1)
|
|
286
|
+
break
|
|
287
|
+
case 'Enter':
|
|
288
|
+
if (open.value && activeIndex.value >= 0) {
|
|
289
|
+
e.preventDefault()
|
|
290
|
+
pick(filtered.value[activeIndex.value])
|
|
291
|
+
}
|
|
292
|
+
break
|
|
293
|
+
case 'Escape':
|
|
294
|
+
if (open.value) {
|
|
295
|
+
e.preventDefault()
|
|
296
|
+
closeMenu()
|
|
297
|
+
}
|
|
298
|
+
break
|
|
299
|
+
case 'Tab':
|
|
300
|
+
closeMenu()
|
|
301
|
+
break
|
|
302
|
+
case 'Backspace':
|
|
303
|
+
if (props.multiple && query.value === '' && selectedOptions.value.length) {
|
|
304
|
+
remove(selectedOptions.value[selectedOptions.value.length - 1])
|
|
305
|
+
}
|
|
306
|
+
break
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function onDocumentMouseDown(e: MouseEvent) {
|
|
311
|
+
if (open.value && rootEl.value && !rootEl.value.contains(e.target as Node)) {
|
|
312
|
+
closeMenu()
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
onMounted(() => document.addEventListener('mousedown', onDocumentMouseDown))
|
|
317
|
+
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocumentMouseDown))
|
|
318
|
+
</script>
|
|
@@ -11,15 +11,15 @@ describe('BaseSelect', () => {
|
|
|
11
11
|
const wrapper = mount(BaseSelect, { slots })
|
|
12
12
|
const select = wrapper.find('select')
|
|
13
13
|
expect(select.classes()).toEqual(
|
|
14
|
-
expect.arrayContaining(['rounded', 'border', 'w-full', '
|
|
14
|
+
expect.arrayContaining(['rounded-lg', 'border', 'w-full', 'pl-2.5', 'py-1.5', 'text-sm', 'appearance-none', 'bg-none']),
|
|
15
15
|
)
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
it('supports md size and inline (non-block) layout', () => {
|
|
19
19
|
const wrapper = mount(BaseSelect, { props: { size: 'md', block: false }, slots })
|
|
20
20
|
const select = wrapper.find('select')
|
|
21
|
-
expect(select.classes()).toEqual(expect.arrayContaining(['
|
|
22
|
-
expect(
|
|
21
|
+
expect(select.classes()).toEqual(expect.arrayContaining(['pl-3', 'py-2']))
|
|
22
|
+
expect(wrapper.classes()).toContain('inline-block')
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
it('reflects modelValue and emits the selected value on change', async () => {
|
|
@@ -1,46 +1,62 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative" :class="block ? 'w-full' : 'inline-block'">
|
|
3
|
+
<select
|
|
4
|
+
v-bind="$attrs"
|
|
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"
|
|
7
|
+
:class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
|
|
8
|
+
@change="onChange"
|
|
9
|
+
>
|
|
10
|
+
<slot />
|
|
11
|
+
</select>
|
|
12
|
+
<ChevronDownIcon
|
|
13
|
+
class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 dark:text-slate-400"
|
|
14
|
+
:class="size === 'md' ? 'right-3' : 'right-2.5'"
|
|
15
|
+
aria-hidden="true"
|
|
16
|
+
/>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
/*
|
|
22
|
+
* Themed select sharing BaseInput's field skin. Options come from the
|
|
23
|
+
* default slot so callers keep full control of <option> rendering (values,
|
|
24
|
+
* titles, disabled entries). Attrs (id, disabled, extra classes) fall
|
|
25
|
+
* through. Same `dark:`-variant note as BaseInput.
|
|
26
|
+
*
|
|
27
|
+
*/
|
|
28
|
+
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
29
|
+
|
|
30
|
+
// Attrs (id, name, disabled, extra classes) go to the <select>, not the
|
|
31
|
+
// chevron wrapper. `bg-none` drops any arrow a forms reset (e.g. Flowbite /
|
|
32
|
+
// @tailwindcss/forms) paints as a background image, so only our chevron shows.
|
|
33
|
+
defineOptions({ inheritAttrs: false })
|
|
34
|
+
|
|
35
|
+
// String-valued only — a DOM select always yields strings; use v-model.number
|
|
36
|
+
// semantics in the owner if needed. The update:modelValue payload is `any`
|
|
37
|
+
// (not generic: vite-plugin-dts flattens SFC generics out of the emitted
|
|
38
|
+
// declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
|
|
39
|
+
// type-checks; the actual payload is always the selected option's string.
|
|
40
|
+
interface Props {
|
|
41
|
+
modelValue?: string | null
|
|
42
|
+
/** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
|
|
43
|
+
size?: 'sm' | 'md'
|
|
44
|
+
/** Full-width (w-full). Set false for inline selects. */
|
|
45
|
+
block?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
withDefaults(defineProps<Props>(), {
|
|
49
|
+
modelValue: undefined,
|
|
50
|
+
size: 'sm',
|
|
51
|
+
block: true,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const emit = defineEmits<{
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
(e: 'update:modelValue', value: any): void
|
|
57
|
+
}>()
|
|
58
|
+
|
|
59
|
+
function onChange(e: Event) {
|
|
60
|
+
emit('update:modelValue', (e.target as HTMLSelectElement).value)
|
|
61
|
+
}
|
|
62
|
+
</script>
|