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.
Files changed (47) hide show
  1. package/README.md +147 -4
  2. package/dist/components/BaseAuthLayout.vue.d.ts +27 -0
  3. package/dist/components/BaseBooleanBadge.vue.d.ts +12 -0
  4. package/dist/components/BaseCard.vue.d.ts +33 -0
  5. package/dist/components/BaseDetailList.vue.d.ts +13 -0
  6. package/dist/components/BaseField.vue.d.ts +38 -0
  7. package/dist/components/BasePageHeader.vue.d.ts +3 -0
  8. package/dist/components/BaseSearchSelect.vue.d.ts +41 -0
  9. package/dist/components/BaseStatCard.vue.d.ts +33 -0
  10. package/dist/components/BaseTable.vue.d.ts +3 -0
  11. package/dist/components/BaseToggle.vue.d.ts +33 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/types/detail.d.ts +10 -0
  14. package/dist/ui-lib.css +1 -1
  15. package/dist/ui-lib.js +1388 -881
  16. package/dist/ui-lib.umd.cjs +1 -1
  17. package/package.json +1 -1
  18. package/src/components/BaseAuthLayout.test.ts +48 -0
  19. package/src/components/BaseAuthLayout.vue +50 -0
  20. package/src/components/BaseBooleanBadge.vue +33 -0
  21. package/src/components/BaseBreadcrumb.vue +68 -68
  22. package/src/components/BaseButton.vue +1 -1
  23. package/src/components/BaseCard.test.ts +55 -0
  24. package/src/components/BaseCard.vue +48 -0
  25. package/src/components/BaseDetailList.test.ts +36 -0
  26. package/src/components/BaseDetailList.vue +43 -0
  27. package/src/components/BaseField.vue +47 -0
  28. package/src/components/BaseInput.test.ts +1 -1
  29. package/src/components/BaseInput.vue +46 -46
  30. package/src/components/BaseLogo.test.ts +26 -0
  31. package/src/components/BaseLogo.vue +7 -1
  32. package/src/components/BasePageHeader.vue +13 -2
  33. package/src/components/BaseSearchSelect.test.ts +117 -0
  34. package/src/components/BaseSearchSelect.vue +318 -0
  35. package/src/components/BaseSelect.test.ts +3 -3
  36. package/src/components/BaseSelect.vue +62 -46
  37. package/src/components/BaseStatCard.test.ts +40 -0
  38. package/src/components/BaseStatCard.vue +103 -0
  39. package/src/components/BaseTable.test.ts +69 -55
  40. package/src/components/BaseTable.vue +62 -50
  41. package/src/components/BaseToggle.test.ts +39 -0
  42. package/src/components/BaseToggle.vue +73 -0
  43. package/src/components/Pagination.test.ts +93 -85
  44. package/src/components/Pagination.vue +87 -82
  45. package/src/index.ts +9 -0
  46. package/src/style.css +58 -58
  47. 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" 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,
@@ -47,10 +47,18 @@
47
47
  <slot name="icon" :icon-class="iconTextClass" />
48
48
  </div>
49
49
  <div>
50
- <h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
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', 'px-2', 'py-1.5', 'text-sm']),
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(['px-3', 'py-2']))
22
- expect(select.classes()).not.toContain('w-full')
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
- Themed select sharing BaseInput's field skin. Options come from the
4
- default slot so callers keep full control of <option> rendering (values,
5
- titles, disabled entries). Attrs (id, disabled, extra classes) fall
6
- through. Same `dark:`-variant note as BaseInput.
7
- -->
8
- <select
9
- :value="modelValue"
10
- 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 cursor-pointer 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"
11
- :class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
12
- @change="onChange"
13
- >
14
- <slot />
15
- </select>
16
- </template>
17
-
18
- <script setup lang="ts">
19
- // String-valued only — a DOM select always yields strings; use v-model.number
20
- // semantics in the owner if needed. The update:modelValue payload is `any`
21
- // (not generic: vite-plugin-dts flattens SFC generics out of the emitted
22
- // declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
23
- // type-checks; the actual payload is always the selected option's string.
24
- interface Props {
25
- modelValue?: string | null
26
- /** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
27
- size?: 'sm' | 'md'
28
- /** Full-width (w-full). Set false for inline selects. */
29
- block?: boolean
30
- }
31
-
32
- withDefaults(defineProps<Props>(), {
33
- modelValue: undefined,
34
- size: 'sm',
35
- block: true,
36
- })
37
-
38
- const emit = defineEmits<{
39
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
- (e: 'update:modelValue', value: any): void
41
- }>()
42
-
43
- function onChange(e: Event) {
44
- emit('update:modelValue', (e.target as HTMLSelectElement).value)
45
- }
46
- </script>
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>