mgv-backoffice 1.23.0 → 1.25.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.
@@ -44,7 +44,7 @@
44
44
  <button
45
45
  type="button"
46
46
  :disabled="submitting"
47
- class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150"
47
+ class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
48
48
  :class="[
49
49
  t.ghostButton,
50
50
  submitting ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
@@ -57,8 +57,9 @@
57
57
  <button
58
58
  type="button"
59
59
  :disabled="submitting"
60
- class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150"
60
+ class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
61
61
  :class="[
62
+ variant === 'danger' ? 'focus-visible:ring-red-500' : 'focus-visible:ring-emerald-500',
62
63
  variant === 'danger'
63
64
  ? submitting ? 'bg-red-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700 cursor-pointer'
64
65
  : submitting ? 'bg-emerald-400 cursor-not-allowed' : 'bg-emerald-600 hover:bg-emerald-700 cursor-pointer',
@@ -75,7 +75,7 @@ const ariaLabel = computed(() => (props.label ? `Copy ${props.label}` : 'Copy'))
75
75
  const buttonClass = computed(() => {
76
76
  if (props.variant === 'bordered') {
77
77
  const base =
78
- 'inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer shrink-0'
78
+ 'inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500'
79
79
  const state = copied.value
80
80
  ? isDark.value
81
81
  ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400'
@@ -86,7 +86,7 @@ const buttonClass = computed(() => {
86
86
  return `${base} ${state}`
87
87
  }
88
88
  // ghost
89
- const base = 'p-1 rounded transition-colors cursor-pointer'
89
+ const base = 'p-1 rounded transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500'
90
90
  const state = isDark.value
91
91
  ? 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-500/10'
92
92
  : 'text-gray-500 hover:text-emerald-600 hover:bg-emerald-50'
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseDropdown from './BaseDropdown.vue'
4
+ import type { DropdownOption } from '../types/dropdown'
5
+
6
+ const options: DropdownOption[] = [
7
+ { value: '1', label: 'Alice' },
8
+ { value: '2', label: 'Bob', title: 'Bob the builder' },
9
+ { value: '3', label: 'Carol', disabled: true },
10
+ ]
11
+
12
+ describe('BaseDropdown', () => {
13
+ it('shows the placeholder when nothing is selected', () => {
14
+ const wrapper = mount(BaseDropdown, {
15
+ props: { options, modelValue: null, placeholder: 'Select Social User' },
16
+ })
17
+ const trigger = wrapper.get('button')
18
+ expect(trigger.text()).toContain('Select Social User')
19
+ expect(trigger.attributes('aria-expanded')).toBe('false')
20
+ // Menu is closed until the trigger is clicked.
21
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
22
+ })
23
+
24
+ it('shows the selected option label instead of the placeholder', () => {
25
+ const wrapper = mount(BaseDropdown, {
26
+ props: { options, modelValue: '2', placeholder: 'Select Social User' },
27
+ })
28
+ expect(wrapper.get('button').text()).toContain('Bob')
29
+ expect(wrapper.get('button').text()).not.toContain('Select Social User')
30
+ })
31
+
32
+ it('opens the menu and renders one row per option on click', async () => {
33
+ const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
34
+ await wrapper.get('button').trigger('click')
35
+ expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
36
+ const rows = wrapper.findAll('[role="option"]')
37
+ expect(rows.map((r) => r.text())).toEqual(['Alice', 'Bob', 'Carol'])
38
+ expect(rows[1].attributes('title')).toBe('Bob the builder')
39
+ })
40
+
41
+ it('emits update:modelValue and closes when a row is chosen', async () => {
42
+ const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
43
+ await wrapper.get('button').trigger('click')
44
+ await wrapper.findAll('[role="option"] button')[1].trigger('click')
45
+ expect(wrapper.emitted('update:modelValue')).toEqual([['2']])
46
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
47
+ })
48
+
49
+ it('does not emit for a disabled row', async () => {
50
+ const wrapper = mount(BaseDropdown, { props: { options, modelValue: null } })
51
+ await wrapper.get('button').trigger('click')
52
+ await wrapper.findAll('[role="option"] button')[2].trigger('click')
53
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
54
+ })
55
+
56
+ it('marks the selected row with aria-selected and the active fill', async () => {
57
+ const wrapper = mount(BaseDropdown, { props: { options, modelValue: '1' } })
58
+ await wrapper.get('button').trigger('click')
59
+ const rows = wrapper.findAll('[role="option"]')
60
+ expect(rows[0].attributes('aria-selected')).toBe('true')
61
+ expect(rows[1].attributes('aria-selected')).toBe('false')
62
+ expect(rows[0].get('button').classes()).toContain('bg-emerald-500')
63
+ })
64
+ })
@@ -0,0 +1,154 @@
1
+ <template>
2
+ <!--
3
+ Button-style single-select dropdown ("Select Social User ⌄"). Unlike
4
+ BaseSelect (a themed native <select>), this renders a trigger button plus
5
+ a floating menu, so the closed control shows a placeholder and a chevron
6
+ that rotates while open — matching the app's filter dropdowns.
7
+
8
+ Options are passed as data (not <option> slots) so the menu rows can carry
9
+ tooltips and disabled state. Selecting a row emits its `value` and closes
10
+ the menu; Escape and an outside click also close it.
11
+
12
+ Same `dark:`-variant note as BaseSelect: the slate skin ships static
13
+ light/dark classes so consumers get both themes without extra config.
14
+ -->
15
+ <div
16
+ ref="rootEl"
17
+ :class="block ? 'relative w-full' : 'relative inline-block'"
18
+ >
19
+ <button
20
+ type="button"
21
+ :class="[triggerClass, block ? 'w-full' : '']"
22
+ :disabled="disabled"
23
+ :aria-label="ariaLabel || undefined"
24
+ aria-haspopup="listbox"
25
+ :aria-expanded="open"
26
+ @click="toggle"
27
+ >
28
+ <span :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
29
+ {{ selectedOption ? selectedOption.label : placeholder }}
30
+ </span>
31
+ <ChevronDownIcon
32
+ class="w-5 h-5 shrink-0 text-slate-500 dark:text-slate-400 transition-transform"
33
+ :class="open ? 'rotate-180' : ''"
34
+ aria-hidden="true"
35
+ />
36
+ </button>
37
+
38
+ <ul
39
+ v-if="open"
40
+ role="listbox"
41
+ :aria-label="ariaLabel || placeholder"
42
+ class="absolute z-20 mt-1 w-full max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
43
+ >
44
+ <li
45
+ v-for="opt in options"
46
+ :key="String(opt.value)"
47
+ role="option"
48
+ :aria-selected="opt.value === modelValue"
49
+ :title="opt.title"
50
+ >
51
+ <button
52
+ type="button"
53
+ :disabled="opt.disabled"
54
+ :class="rowClass(opt)"
55
+ @click="select(opt)"
56
+ >
57
+ {{ opt.label }}
58
+ </button>
59
+ </li>
60
+ </ul>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup lang="ts">
65
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
66
+ import { ChevronDownIcon } from '@heroicons/vue/24/outline'
67
+ import { useEscapeKey } from '../composables/useEscapeKey'
68
+ import type { DropdownOption } from '../types/dropdown'
69
+
70
+ // NOT generic on purpose: vite-plugin-dts flattens SFC generics out of the
71
+ // emitted declarations, so consumers would see the erased constraint anyway.
72
+ // The update:modelValue payload is `any` so `v-model` on a union-typed ref
73
+ // type-checks; the actual payload is always one of the options' values.
74
+ interface Props {
75
+ options: readonly DropdownOption[]
76
+ modelValue?: string | number | null
77
+ /** Trigger text shown when nothing is selected. */
78
+ placeholder?: string
79
+ /** 'md' = px-4 py-2.5 (default, the app filter height); 'sm' = px-3 py-2. */
80
+ size?: 'sm' | 'md'
81
+ /** Full-width (w-full). Set false for an inline, content-width dropdown. */
82
+ block?: boolean
83
+ disabled?: boolean
84
+ /** Accessible name for the trigger/listbox when there is no visible label. */
85
+ ariaLabel?: string
86
+ }
87
+
88
+ const props = withDefaults(defineProps<Props>(), {
89
+ modelValue: null,
90
+ placeholder: 'Select',
91
+ size: 'md',
92
+ block: true,
93
+ disabled: false,
94
+ ariaLabel: '',
95
+ })
96
+
97
+ const emit = defineEmits<{
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ (e: 'update:modelValue', value: any): void
100
+ }>()
101
+
102
+ const open = ref(false)
103
+ const rootEl = ref<HTMLElement | null>(null)
104
+
105
+ const selectedOption = computed(() =>
106
+ props.options.find((o) => o.value === props.modelValue) ?? null,
107
+ )
108
+
109
+ const triggerClass = computed(() => {
110
+ const padding = props.size === 'sm' ? 'px-3 py-2' : 'px-4 py-2.5'
111
+ return (
112
+ 'flex items-center justify-between gap-2 rounded-lg border text-left text-sm transition-colors ' +
113
+ 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 ' +
114
+ 'hover:bg-slate-50 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 ' +
115
+ 'cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ' +
116
+ padding
117
+ )
118
+ })
119
+
120
+ function rowClass(opt: DropdownOption): string {
121
+ const selected = opt.value === props.modelValue
122
+ const base =
123
+ 'block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
124
+ const fill = selected
125
+ ? 'bg-emerald-500 text-white'
126
+ : 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
127
+ return `${base} ${fill}`
128
+ }
129
+
130
+ function toggle() {
131
+ if (props.disabled) return
132
+ open.value = !open.value
133
+ }
134
+
135
+ function select(opt: DropdownOption) {
136
+ if (opt.disabled) return
137
+ emit('update:modelValue', opt.value)
138
+ open.value = false
139
+ }
140
+
141
+ useEscapeKey(() => {
142
+ open.value = false
143
+ })
144
+
145
+ function onDocumentClick(e: MouseEvent) {
146
+ if (!open.value) return
147
+ if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
148
+ open.value = false
149
+ }
150
+ }
151
+
152
+ onMounted(() => document.addEventListener('click', onDocumentClick))
153
+ onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
154
+ </script>
@@ -55,10 +55,12 @@
55
55
 
56
56
  <!-- Item list -->
57
57
  <div v-if="!loading && !loadError" class="mt-3 max-h-64 overflow-y-auto">
58
- <div
58
+ <button
59
59
  v-for="item in filteredItems"
60
60
  :key="item.id"
61
- class="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors"
61
+ type="button"
62
+ :aria-pressed="selectedId === item.id"
63
+ class="w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
62
64
  :class="[
63
65
  selectedId === item.id
64
66
  ? palette.selectedRow
@@ -81,7 +83,7 @@
81
83
  <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
82
84
  </svg>
83
85
  </div>
84
- </div>
86
+ </button>
85
87
 
86
88
  <!-- Empty states -->
87
89
  <div v-if="items.length === 0" class="text-center py-6">
@@ -100,7 +102,7 @@
100
102
  <button
101
103
  type="button"
102
104
  :disabled="submitting"
103
- class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
105
+ class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
104
106
  :class="t.ghostButton"
105
107
  @click="handleCancel"
106
108
  >
@@ -109,7 +111,7 @@
109
111
  <button
110
112
  type="button"
111
113
  :disabled="!selectedId || submitting"
112
- class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150"
114
+ class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
113
115
  :class="!selectedId || submitting ? palette.confirmDisabled : palette.confirmActive"
114
116
  @click="handleConfirm"
115
117
  >
@@ -11,7 +11,7 @@
11
11
  <input
12
12
  :value="modelValue"
13
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"
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
15
  :class="[block ? 'w-full' : '', size === 'sm' ? 'px-2 py-1.5' : 'px-3 py-2']"
16
16
  @input="onInput"
17
17
  />
@@ -8,7 +8,7 @@
8
8
  <p class="text-lg mb-6" :class="t.dimTextAlt">{{ message }}</p>
9
9
  <button
10
10
  @click="goHome"
11
- class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors cursor-pointer"
11
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
12
12
  :class="isDark ? 'bg-emerald-600 hover:bg-emerald-500' : 'bg-emerald-600 hover:bg-emerald-700'"
13
13
  >
14
14
  {{ homeLabel }}
@@ -75,12 +75,12 @@ const wrapClass = computed(() =>
75
75
  const buttonClass = computed(() => {
76
76
  if (props.variant === 'toolbar') {
77
77
  return (
78
- 'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors ' +
78
+ 'flex items-center px-3.5 text-xs font-semibold uppercase tracking-wide transition-colors cursor-pointer ' +
79
79
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500'
80
80
  )
81
81
  }
82
82
  const padding = props.variant === 'wide' ? 'px-4 py-2' : 'px-3 py-2'
83
- return `${padding} text-sm font-medium transition-colors border-l first:border-l-0 border-slate-300 dark:border-slate-600`
83
+ return `${padding} text-sm font-medium transition-colors cursor-pointer border-l first:border-l-0 border-slate-300 dark:border-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500`
84
84
  })
85
85
 
86
86
  const activeFill = computed(() =>
@@ -7,7 +7,7 @@
7
7
  -->
8
8
  <select
9
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"
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
11
  :class="[block ? 'w-full' : '', size === 'md' ? 'px-3 py-2' : 'px-2 py-1.5']"
12
12
  @change="onChange"
13
13
  >
@@ -94,7 +94,7 @@ function navigate(name: string) {
94
94
  <!-- Mobile top bar (hidden on desktop via scoped CSS to avoid flex/hidden conflict) -->
95
95
  <div class="mobile-topbar fixed top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3 border-b transition-colors"
96
96
  :class="isDark ? 'bg-gray-900 border-gray-800' : 'bg-white border-gray-200'">
97
- <button type="button" @click="goHome" class="cursor-pointer" aria-label="Go to home">
97
+ <button type="button" @click="goHome" class="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500" aria-label="Go to home">
98
98
  <!--
99
99
  `logo` slot: consumers drop their brand logo in here. Receives
100
100
  the current size hint so a logo component can scale to match.
@@ -104,7 +104,7 @@ function navigate(name: string) {
104
104
  <button
105
105
  type="button"
106
106
  @click="toggleMobile"
107
- class="p-2 rounded-lg cursor-pointer"
107
+ class="p-2 rounded-lg cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
108
108
  :class="isDark ? 'text-gray-400 hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100'"
109
109
  :aria-label="mobileOpen ? 'Close navigation menu' : 'Open navigation menu'"
110
110
  :aria-expanded="mobileOpen"
@@ -138,7 +138,7 @@ function navigate(name: string) {
138
138
  class="px-5 pt-5 pb-4 flex items-center justify-center relative"
139
139
  :class="{ 'lg:px-2': isRail }"
140
140
  >
141
- <button type="button" @click="goHome" class="cursor-pointer" aria-label="Go to home">
141
+ <button type="button" @click="goHome" class="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500" aria-label="Go to home">
142
142
  <slot name="logo" :size="isRail ? 36 : 52" />
143
143
  </button>
144
144
  <button
@@ -53,7 +53,7 @@
53
53
  <template #footer>
54
54
  <button
55
55
  type="button"
56
- class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150"
56
+ class="inline-flex items-center justify-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium transition-colors duration-150 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
57
57
  :class="t.ghostButton"
58
58
  @click="handleCancel"
59
59
  >
@@ -62,7 +62,7 @@
62
62
  <button
63
63
  type="button"
64
64
  :disabled="!inputValue.trim() || submitting"
65
- class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150"
65
+ class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
66
66
  :class="[
67
67
  !inputValue.trim() || submitting
68
68
  ? 'bg-emerald-400 cursor-not-allowed'
@@ -21,7 +21,7 @@
21
21
  :type="type"
22
22
  :disabled="disabled"
23
23
  :title="title"
24
- class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
24
+ class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
25
25
  :class="variantClass"
26
26
  @click="$emit('click', $event)"
27
27
  >
@@ -1,9 +1,9 @@
1
1
  <template>
2
- <span v-if="hasAmount && shouldBeGreen" class="font-bold text-green-800">
2
+ <span v-if="hasAmount && shouldBeGreen" class="font-bold text-green-800 dark:text-green-400">
3
3
  {{ amount }} <span v-if="showCurrency">&euro;</span>
4
4
  </span>
5
5
 
6
- <span v-if="hasAmount && !shouldBeGreen" :class="[baseClasses, shouldBeRed ? 'text-red-800' : 'text-gray-800']">
6
+ <span v-if="hasAmount && !shouldBeGreen" :class="[baseClasses, shouldBeRed ? 'text-red-800 dark:text-red-400' : 'text-gray-800 dark:text-gray-200']">
7
7
  {{ amount }} <span v-if="showCurrency">&euro;</span>
8
8
  </span>
9
9
  </template>
@@ -8,7 +8,7 @@ function cellLabels(wrapper: ReturnType<typeof mount>): string[] {
8
8
  }
9
9
 
10
10
  async function clickPage(wrapper: ReturnType<typeof mount>, page: number) {
11
- const link = wrapper.findAll('a').find(a => a.text().trim() === String(page))
11
+ const link = wrapper.findAll('button').find(b => b.text().trim() === String(page))
12
12
  expect(link, `expected a visible cell for page ${page}`).toBeTruthy()
13
13
  await link!.trigger('click')
14
14
  }
@@ -80,6 +80,6 @@ describe('Pagination', () => {
80
80
  await clickPage(wrapper, 7)
81
81
  await wrapper.setProps({ totalItems: 40 })
82
82
  expect(cellLabels(wrapper)).toEqual(['1', '2'])
83
- expect(wrapper.find('a.active').text()).toBe('2')
83
+ expect(wrapper.find('button.active').text()).toBe('2')
84
84
  })
85
85
  })
@@ -1,20 +1,20 @@
1
1
  <template>
2
- <div>
2
+ <nav aria-label="Pagination">
3
3
  <ul class="flex list-none p-0 m-0">
4
4
  <li v-for="(cell, index) in cells" :key="index" class="mr-1">
5
- <a
5
+ <button
6
6
  v-if="cell !== 0"
7
- role="button"
8
- tabindex="0"
7
+ type="button"
8
+ :aria-current="cell === currentPage ? 'page' : undefined"
9
+ :aria-label="`Go to page ${cell}`"
9
10
  @click="changePage(cell)"
10
- @keydown.enter="changePage(cell)"
11
- class="inline-block border border-gray-300 rounded-lg p-3 no-underline text-black cursor-pointer hover:bg-gray-300"
12
- :class="{ 'active font-bold bg-gray-200': cell === currentPage }"
13
- >{{ cell }}</a>
14
- <span v-else class="inline-block border border-gray-300 rounded-lg p-3 text-black cursor-not-allowed">...</span>
11
+ class="inline-block min-w-[44px] border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-pointer transition-colors hover:bg-gray-300 dark:hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
12
+ :class="{ 'active font-bold bg-gray-200 dark:bg-gray-700': cell === currentPage }"
13
+ >{{ cell }}</button>
14
+ <span v-else aria-hidden="true" class="inline-block border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-black dark:text-gray-200 cursor-not-allowed">...</span>
15
15
  </li>
16
16
  </ul>
17
- </div>
17
+ </nav>
18
18
  </template>
19
19
 
20
20
  <script lang="ts" setup>
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export { default as BaseNotificationPanel } from './components/BaseNotificationP
37
37
  // Components — extracted from TradeAutomation
38
38
  export { default as BaseInput } from './components/BaseInput.vue'
39
39
  export { default as BaseSelect } from './components/BaseSelect.vue'
40
+ export { default as BaseDropdown } from './components/BaseDropdown.vue'
40
41
  export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
41
42
  export { default as BaseTable } from './components/BaseTable.vue'
42
43
  export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
@@ -73,6 +74,7 @@ export type { EntityPickerItem } from './types/entityPicker'
73
74
  export type { LoginCredentials } from './types/auth'
74
75
  export type { NotificationItem } from './types/notification'
75
76
  export type { SegmentedOption } from './types/segmented'
77
+ export type { DropdownOption } from './types/dropdown'
76
78
  export type { TableColumn } from './types/table'
77
79
  export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
78
80
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * One choice in a BaseDropdown. `value` is what the dropdown emits, `label`
3
+ * what the trigger and the menu row show; `title` becomes the row's tooltip
4
+ * and `disabled` makes the row unselectable.
5
+ *
6
+ * Generic so callers with union-typed values ('7D' | '30D' | …) keep full
7
+ * type-safety through v-model.
8
+ */
9
+ export interface DropdownOption<T extends string | number = string | number> {
10
+ value: T
11
+ label: string
12
+ title?: string
13
+ disabled?: boolean
14
+ }