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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -1,68 +1,68 @@
1
- <template>
2
- <nav class="flex px-5 py-3 text-gray-700 border border-gray-200 rounded-lg bg-gray-50 dark:bg-gray-800 dark:border-gray-700" aria-label="Breadcrumb">
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-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm placeholder:text-slate-400 dark:placeholder:text-slate-500 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 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>
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" xlink:href="#a" y1="288.291" y2="369.4"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="138.367" x2="138.367" xlink:href="#a" y1="308.454" y2="370.601"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="195.475" x2="195.475" xlink:href="#a" y1="288.291" y2="369.85"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="249.634" x2="249.634" xlink:href="#a" y1="307.312" y2="369.511"/><linearGradient id="f" gradientUnits="userSpaceOnUse" x1="302.884" x2="302.884" xlink:href="#a" y1="288.291" y2="367.857"/><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="354.118" x2="354.118" xlink:href="#a" y1="307.312" y2="369.511"/><linearGradient id="h" gradientUnits="userSpaceOnUse" x1="428.02" x2="428.02" xlink:href="#a" y1="306.156" y2="367.742"/><linearGradient id="i" gradientUnits="userSpaceOnUse" x1="502.066" x2="502.066" xlink:href="#a" y1="288.291" y2="369.4"/><linearGradient id="j" gradientUnits="userSpaceOnUse" x1="283.264" x2="283.264" xlink:href="#a" y1="131.629" y2="232.559"/><linearGradient id="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(#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(#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(#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(#e)"/><path d="m296.4 288.3h13v79.6h-13z" fill="url(#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(#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(#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(#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(#j)"/><path d="m281.7 183.8h3.6v18.5h-3.6z" fill="url(#k)"/></svg>
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
+ })