mgv-backoffice 1.37.0 → 1.38.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 +88 -3
- package/dist/components/BaseCard.vue.d.ts +33 -0
- package/dist/components/BaseField.vue.d.ts +38 -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/index.d.ts +4 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1222 -879
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseBreadcrumb.vue +68 -68
- package/src/components/BaseCard.test.ts +55 -0
- package/src/components/BaseCard.vue +48 -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/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 +61 -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/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<nav class="flex px-5 py-3 text-gray-700 border border-
|
|
3
|
-
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
|
4
|
-
|
|
5
|
-
<li class="inline-flex items-center" style="margin-left: 0;">
|
|
6
|
-
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
7
|
-
<HomeIcon class="h-4 w-4 text-gray-500 mr-2" />
|
|
8
|
-
Home
|
|
9
|
-
</router-link>
|
|
10
|
-
</li>
|
|
11
|
-
|
|
12
|
-
<li v-for="(crumb, index) in crumbs" :key="index" class="inline-flex items-center" style="margin-left: 0;">
|
|
13
|
-
<ChevronRightIcon class="h-4 w-4 text-gray-500" />
|
|
14
|
-
<router-link :to="crumb.url" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
15
|
-
{{ crumb.name }}
|
|
16
|
-
</router-link>
|
|
17
|
-
</li>
|
|
18
|
-
|
|
19
|
-
</ol>
|
|
20
|
-
</nav>
|
|
21
|
-
</template>
|
|
22
|
-
|
|
23
|
-
<script lang="ts" setup>
|
|
24
|
-
import { computed } from 'vue'
|
|
25
|
-
import { HomeIcon } from '@heroicons/vue/24/solid'
|
|
26
|
-
import { ChevronRightIcon } from '@heroicons/vue/24/outline'
|
|
27
|
-
|
|
28
|
-
export interface BreadCrumb {
|
|
29
|
-
name: string
|
|
30
|
-
url: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const props = defineProps({
|
|
34
|
-
items: {
|
|
35
|
-
type: Array as () => BreadCrumb[],
|
|
36
|
-
required: false,
|
|
37
|
-
default: undefined
|
|
38
|
-
},
|
|
39
|
-
path: {
|
|
40
|
-
type: String,
|
|
41
|
-
required: false,
|
|
42
|
-
default: undefined
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
const crumbs = computed<BreadCrumb[]>(() => {
|
|
47
|
-
// If explicit items are provided, use them directly
|
|
48
|
-
if (props.items && props.items.length > 0) {
|
|
49
|
-
return props.items
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Otherwise, auto-generate from the path prop (or current URL)
|
|
53
|
-
const pathname = props.path ?? (typeof window !== 'undefined' ? window.location.pathname : '/')
|
|
54
|
-
const segments = pathname.split('/').filter(s => s !== '')
|
|
55
|
-
|
|
56
|
-
let accumulated = ''
|
|
57
|
-
return segments.map(segment => {
|
|
58
|
-
accumulated += '/' + segment
|
|
59
|
-
|
|
60
|
-
const isNumeric = !Number.isNaN(Number(segment))
|
|
61
|
-
const name = !isNumeric
|
|
62
|
-
? (segment.charAt(0).toUpperCase() + segment.slice(1)).replaceAll('-', ' ')
|
|
63
|
-
: segment
|
|
64
|
-
|
|
65
|
-
return { name, url: accumulated }
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="flex overflow-x-auto px-5 py-3 text-gray-700 border border-slate-200 rounded-xl bg-white shadow-sm dark:bg-slate-900 dark:border-slate-700" aria-label="Breadcrumb">
|
|
3
|
+
<ol class="inline-flex items-center whitespace-nowrap space-x-1 md:space-x-3">
|
|
4
|
+
|
|
5
|
+
<li class="inline-flex items-center" style="margin-left: 0;">
|
|
6
|
+
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
7
|
+
<HomeIcon class="h-4 w-4 text-gray-500 mr-2" />
|
|
8
|
+
Home
|
|
9
|
+
</router-link>
|
|
10
|
+
</li>
|
|
11
|
+
|
|
12
|
+
<li v-for="(crumb, index) in crumbs" :key="index" class="inline-flex items-center" style="margin-left: 0;">
|
|
13
|
+
<ChevronRightIcon class="h-4 w-4 text-gray-500" />
|
|
14
|
+
<router-link :to="crumb.url" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
15
|
+
{{ crumb.name }}
|
|
16
|
+
</router-link>
|
|
17
|
+
</li>
|
|
18
|
+
|
|
19
|
+
</ol>
|
|
20
|
+
</nav>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script lang="ts" setup>
|
|
24
|
+
import { computed } from 'vue'
|
|
25
|
+
import { HomeIcon } from '@heroicons/vue/24/solid'
|
|
26
|
+
import { ChevronRightIcon } from '@heroicons/vue/24/outline'
|
|
27
|
+
|
|
28
|
+
export interface BreadCrumb {
|
|
29
|
+
name: string
|
|
30
|
+
url: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = defineProps({
|
|
34
|
+
items: {
|
|
35
|
+
type: Array as () => BreadCrumb[],
|
|
36
|
+
required: false,
|
|
37
|
+
default: undefined
|
|
38
|
+
},
|
|
39
|
+
path: {
|
|
40
|
+
type: String,
|
|
41
|
+
required: false,
|
|
42
|
+
default: undefined
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const crumbs = computed<BreadCrumb[]>(() => {
|
|
47
|
+
// If explicit items are provided, use them directly
|
|
48
|
+
if (props.items && props.items.length > 0) {
|
|
49
|
+
return props.items
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Otherwise, auto-generate from the path prop (or current URL)
|
|
53
|
+
const pathname = props.path ?? (typeof window !== 'undefined' ? window.location.pathname : '/')
|
|
54
|
+
const segments = pathname.split('/').filter(s => s !== '')
|
|
55
|
+
|
|
56
|
+
let accumulated = ''
|
|
57
|
+
return segments.map(segment => {
|
|
58
|
+
accumulated += '/' + segment
|
|
59
|
+
|
|
60
|
+
const isNumeric = !Number.isNaN(Number(segment))
|
|
61
|
+
const name = !isNumeric
|
|
62
|
+
? (segment.charAt(0).toUpperCase() + segment.slice(1)).replaceAll('-', ' ')
|
|
63
|
+
: segment
|
|
64
|
+
|
|
65
|
+
return { name, url: accumulated }
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
</script>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseCard from './BaseCard.vue'
|
|
4
|
+
import BaseField from './BaseField.vue'
|
|
5
|
+
|
|
6
|
+
describe('BaseCard', () => {
|
|
7
|
+
it('renders the bordered surface with the body slot', () => {
|
|
8
|
+
const wrapper = mount(BaseCard, { slots: { default: '<p>body</p>' } })
|
|
9
|
+
expect(wrapper.classes()).toEqual(expect.arrayContaining(['rounded-xl', 'border', 'shadow-sm']))
|
|
10
|
+
expect(wrapper.find('p').text()).toBe('body')
|
|
11
|
+
expect(wrapper.find('header').exists()).toBe(false)
|
|
12
|
+
expect(wrapper.find('footer').exists()).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('renders header (title, subtitle, actions) and footer when given', () => {
|
|
16
|
+
const wrapper = mount(BaseCard, {
|
|
17
|
+
props: { title: 'Template', subtitle: 'Edit it' },
|
|
18
|
+
slots: { actions: '<button>New</button>', footer: '<button>Save</button>' },
|
|
19
|
+
})
|
|
20
|
+
expect(wrapper.find('header h2').text()).toBe('Template')
|
|
21
|
+
expect(wrapper.find('header').text()).toContain('Edit it')
|
|
22
|
+
expect(wrapper.find('header button').text()).toBe('New')
|
|
23
|
+
expect(wrapper.find('footer button').text()).toBe('Save')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('supports compact and no body padding', () => {
|
|
27
|
+
expect(mount(BaseCard, { props: { padding: 'sm' } }).find('section > div').classes()).toContain('p-4')
|
|
28
|
+
expect(mount(BaseCard, { props: { padding: 'none' } }).find('section > div').classes()).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('BaseField', () => {
|
|
33
|
+
it('renders label (wired to the control), control and hint', () => {
|
|
34
|
+
const wrapper = mount(BaseField, {
|
|
35
|
+
props: { label: 'Name', labelFor: 'name', hint: 'Your name', required: true },
|
|
36
|
+
slots: { default: '<input id="name" />' },
|
|
37
|
+
})
|
|
38
|
+
expect(wrapper.find('label').attributes('for')).toBe('name')
|
|
39
|
+
expect(wrapper.find('label').text()).toBe('Name*')
|
|
40
|
+
expect(wrapper.find('input').exists()).toBe(true)
|
|
41
|
+
expect(wrapper.text()).toContain('Your name')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('shows the error instead of the hint', () => {
|
|
45
|
+
const wrapper = mount(BaseField, { props: { label: 'Name', hint: 'h', error: 'Required' } })
|
|
46
|
+
expect(wrapper.find('[role=alert]').text()).toBe('Required')
|
|
47
|
+
expect(wrapper.text()).not.toContain('h\n')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('uses the small uppercase label and flex growth for filter bars', () => {
|
|
51
|
+
const wrapper = mount(BaseField, { props: { label: 'Search', compact: true, grow: true } })
|
|
52
|
+
expect(wrapper.find('label').classes()).toEqual(expect.arrayContaining(['uppercase', 'text-xs']))
|
|
53
|
+
expect(wrapper.classes()).toContain('flex-1')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
class="rounded-xl border border-slate-200 bg-white text-left shadow-sm dark:border-slate-700 dark:bg-slate-900"
|
|
4
|
+
>
|
|
5
|
+
<header
|
|
6
|
+
v-if="title || subtitle || $slots.actions"
|
|
7
|
+
class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700"
|
|
8
|
+
>
|
|
9
|
+
<div class="min-w-0">
|
|
10
|
+
<h2 v-if="title" class="text-base font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
|
|
11
|
+
<p v-if="subtitle" class="text-sm text-slate-500 dark:text-slate-400">{{ subtitle }}</p>
|
|
12
|
+
</div>
|
|
13
|
+
<div v-if="$slots.actions" class="flex items-center gap-2">
|
|
14
|
+
<slot name="actions" />
|
|
15
|
+
</div>
|
|
16
|
+
</header>
|
|
17
|
+
<div :class="padding === 'sm' ? 'p-4' : padding === 'none' ? '' : 'p-5'">
|
|
18
|
+
<slot />
|
|
19
|
+
</div>
|
|
20
|
+
<footer
|
|
21
|
+
v-if="$slots.footer"
|
|
22
|
+
class="flex flex-wrap items-center justify-end gap-2 border-t border-slate-200 px-5 py-3 dark:border-slate-700"
|
|
23
|
+
>
|
|
24
|
+
<slot name="footer" />
|
|
25
|
+
</footer>
|
|
26
|
+
</section>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
/**
|
|
31
|
+
* Bordered surface card — the shared chrome for forms, filter/search panels
|
|
32
|
+
* and any grouped content. Optional header (title / subtitle / `actions`
|
|
33
|
+
* slot), a padded body (default slot) and an optional `footer` slot for
|
|
34
|
+
* submit / cancel buttons.
|
|
35
|
+
*/
|
|
36
|
+
interface Props {
|
|
37
|
+
title?: string
|
|
38
|
+
subtitle?: string
|
|
39
|
+
/** Body padding: 'md' (p-5, default), 'sm' (p-4, e.g. filter bars) or 'none'. */
|
|
40
|
+
padding?: 'md' | 'sm' | 'none'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
withDefaults(defineProps<Props>(), {
|
|
44
|
+
title: '',
|
|
45
|
+
subtitle: '',
|
|
46
|
+
padding: 'md',
|
|
47
|
+
})
|
|
48
|
+
</script>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="grow ? 'min-w-48 flex-1' : ''" class="text-left">
|
|
3
|
+
<label
|
|
4
|
+
v-if="label"
|
|
5
|
+
:for="labelFor || undefined"
|
|
6
|
+
class="mb-1 block"
|
|
7
|
+
:class="compact
|
|
8
|
+
? 'text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400'
|
|
9
|
+
: 'text-sm font-medium text-slate-700 dark:text-slate-200'"
|
|
10
|
+
>
|
|
11
|
+
{{ label }}<span v-if="required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
|
|
12
|
+
</label>
|
|
13
|
+
<slot />
|
|
14
|
+
<p v-if="error" class="mt-1 text-xs font-medium text-red-600 dark:text-red-400" role="alert">{{ error }}</p>
|
|
15
|
+
<p v-else-if="hint" class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ hint }}</p>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
/**
|
|
21
|
+
* Label + control + message wrapper for forms and filter bars. The control
|
|
22
|
+
* goes in the default slot (BaseInput, BaseSelect, BaseSearchSelect, …).
|
|
23
|
+
* `compact` switches to the small uppercase filter-bar label; `grow` makes
|
|
24
|
+
* the field take the free space in a flex row (filter bars).
|
|
25
|
+
*/
|
|
26
|
+
interface Props {
|
|
27
|
+
label?: string
|
|
28
|
+
/** id of the control, wired to the label's `for`. */
|
|
29
|
+
labelFor?: string
|
|
30
|
+
required?: boolean
|
|
31
|
+
/** Validation message (red); replaces the hint while set. */
|
|
32
|
+
error?: string
|
|
33
|
+
hint?: string
|
|
34
|
+
compact?: boolean
|
|
35
|
+
grow?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
withDefaults(defineProps<Props>(), {
|
|
39
|
+
label: '',
|
|
40
|
+
labelFor: '',
|
|
41
|
+
required: false,
|
|
42
|
+
error: '',
|
|
43
|
+
hint: '',
|
|
44
|
+
compact: false,
|
|
45
|
+
grow: false,
|
|
46
|
+
})
|
|
47
|
+
</script>
|
|
@@ -7,7 +7,7 @@ describe('BaseInput', () => {
|
|
|
7
7
|
const wrapper = mount(BaseInput)
|
|
8
8
|
const input = wrapper.find('input')
|
|
9
9
|
expect(input.classes()).toEqual(
|
|
10
|
-
expect.arrayContaining(['rounded', 'border', 'w-full', 'px-3', 'py-2', 'text-sm']),
|
|
10
|
+
expect.arrayContaining(['rounded-lg', 'border', 'w-full', 'px-3', 'py-2', 'text-sm']),
|
|
11
11
|
)
|
|
12
12
|
expect(input.attributes('type')).toBe('text')
|
|
13
13
|
})
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!--
|
|
3
|
-
Themed text/number input. Carries the shared field skin (slate border,
|
|
4
|
-
slate-50/slate-900 surface); everything else — placeholder, id, disabled,
|
|
5
|
-
step/min, extra classes like `font-mono` — falls through via attrs and
|
|
6
|
-
Vue's class merging, so callers keep their one-off styling.
|
|
7
|
-
|
|
8
|
-
Dark mode uses `dark:` variants: the consuming app must map the `dark`
|
|
9
|
-
variant to the `.dark` class useTheme() toggles (all current consumers do).
|
|
10
|
-
-->
|
|
11
|
-
<input
|
|
12
|
-
:value="modelValue"
|
|
13
|
-
:type="type"
|
|
14
|
-
class="rounded border border-slate-300 dark:border-slate-600 bg-
|
|
15
|
-
:class="[block ? 'w-full' : '', size === 'sm' ? 'px-2 py-1.5' : 'px-3 py-2']"
|
|
16
|
-
@input="onInput"
|
|
17
|
-
/>
|
|
18
|
-
</template>
|
|
19
|
-
|
|
20
|
-
<script setup lang="ts">
|
|
21
|
-
interface Props {
|
|
22
|
-
modelValue?: string | number | null
|
|
23
|
-
/** Native input type ('text', 'number', 'password', …). */
|
|
24
|
-
type?: string
|
|
25
|
-
/** 'md' = px-3 py-2 (default); 'sm' = px-2 py-1.5 compact. */
|
|
26
|
-
size?: 'sm' | 'md'
|
|
27
|
-
/** Full-width (w-full). Set false for inline fields. */
|
|
28
|
-
block?: boolean
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
withDefaults(defineProps<Props>(), {
|
|
32
|
-
modelValue: '',
|
|
33
|
-
type: 'text',
|
|
34
|
-
size: 'md',
|
|
35
|
-
block: true,
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
const emit = defineEmits<{
|
|
39
|
-
/** Raw string value on every input — callers parse numbers themselves. */
|
|
40
|
-
(e: 'update:modelValue', value: string): void
|
|
41
|
-
}>()
|
|
42
|
-
|
|
43
|
-
function onInput(e: Event) {
|
|
44
|
-
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
|
45
|
-
}
|
|
46
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Themed text/number input. Carries the shared field skin (slate border,
|
|
4
|
+
slate-50/slate-900 surface); everything else — placeholder, id, disabled,
|
|
5
|
+
step/min, extra classes like `font-mono` — falls through via attrs and
|
|
6
|
+
Vue's class merging, so callers keep their one-off styling.
|
|
7
|
+
|
|
8
|
+
Dark mode uses `dark:` variants: the consuming app must map the `dark`
|
|
9
|
+
variant to the `.dark` class useTheme() toggles (all current consumers do).
|
|
10
|
+
-->
|
|
11
|
+
<input
|
|
12
|
+
:value="modelValue"
|
|
13
|
+
:type="type"
|
|
14
|
+
class="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 placeholder:text-slate-400 dark:placeholder:text-slate-500 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"
|
|
15
|
+
:class="[block ? 'w-full' : '', size === 'sm' ? 'px-2 py-1.5' : 'px-3 py-2']"
|
|
16
|
+
@input="onInput"
|
|
17
|
+
/>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
interface Props {
|
|
22
|
+
modelValue?: string | number | null
|
|
23
|
+
/** Native input type ('text', 'number', 'password', …). */
|
|
24
|
+
type?: string
|
|
25
|
+
/** 'md' = px-3 py-2 (default); 'sm' = px-2 py-1.5 compact. */
|
|
26
|
+
size?: 'sm' | 'md'
|
|
27
|
+
/** Full-width (w-full). Set false for inline fields. */
|
|
28
|
+
block?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
withDefaults(defineProps<Props>(), {
|
|
32
|
+
modelValue: '',
|
|
33
|
+
type: 'text',
|
|
34
|
+
size: 'md',
|
|
35
|
+
block: true,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
/** Raw string value on every input — callers parse numbers themselves. */
|
|
40
|
+
(e: 'update:modelValue', value: string): void
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
function onInput(e: Event) {
|
|
44
|
+
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
import { mount, RouterLinkStub } from '@vue/test-utils'
|
|
4
|
+
import BaseLogo from './BaseLogo.vue'
|
|
5
|
+
|
|
6
|
+
describe('BaseLogo', () => {
|
|
7
|
+
it('gives each instance its own gradient ids and references them', () => {
|
|
8
|
+
const Two = defineComponent({ render: () => [h(BaseLogo), h(BaseLogo)] })
|
|
9
|
+
const wrapper = mount(Two, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
|
10
|
+
|
|
11
|
+
const svgs = wrapper.findAll('svg')
|
|
12
|
+
expect(svgs).toHaveLength(2)
|
|
13
|
+
|
|
14
|
+
const ids = svgs.map((svg) => svg.findAll('linearGradient').map((g) => g.attributes('id')))
|
|
15
|
+
// No id is shared between the two logos.
|
|
16
|
+
expect(ids[0].filter((id) => ids[1].includes(id))).toEqual([])
|
|
17
|
+
|
|
18
|
+
// Every fill in a logo points at a gradient inside that same logo.
|
|
19
|
+
svgs.forEach((svg, i) => {
|
|
20
|
+
svg.findAll('path').forEach((path) => {
|
|
21
|
+
const ref = path.attributes('fill')?.match(/^url\(#(.+)\)$/)?.[1]
|
|
22
|
+
expect(ids[i]).toContain(ref)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -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,
|
|
@@ -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
|
+
})
|