mgv-backoffice 1.38.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.38.0",
3
+ "version": "1.40.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, RouterLinkStub } from '@vue/test-utils'
3
+ import BaseAuthLayout from './BaseAuthLayout.vue'
4
+ import BaseBooleanBadge from './BaseBooleanBadge.vue'
5
+
6
+ const global = { stubs: { RouterLink: RouterLinkStub } }
7
+
8
+ describe('BaseAuthLayout', () => {
9
+ it('renders the default logo, title, content and footer', () => {
10
+ const wrapper = mount(BaseAuthLayout, {
11
+ props: { title: 'Sign in' },
12
+ slots: { default: '<form>form</form>', footer: 'No account? <a>Sign up</a>' },
13
+ global,
14
+ })
15
+ expect(wrapper.find('svg').exists()).toBe(true)
16
+ expect(wrapper.find('h1').text()).toBe('Sign in')
17
+ expect(wrapper.find('form').text()).toBe('form')
18
+ expect(wrapper.text()).toContain('No account? Sign up')
19
+ })
20
+
21
+ it('lets the logo slot replace the default logo', () => {
22
+ const wrapper = mount(BaseAuthLayout, { slots: { logo: '<img alt="brand" />' }, global })
23
+ expect(wrapper.find('img').exists()).toBe(true)
24
+ expect(wrapper.find('svg').exists()).toBe(false)
25
+ })
26
+
27
+ it('widens the card with `wide`', () => {
28
+ const card = (w: ReturnType<typeof mount>) => w.find('.rounded-xl')
29
+ expect(card(mount(BaseAuthLayout, { global })).classes()).toContain('max-w-md')
30
+ expect(card(mount(BaseAuthLayout, { props: { wide: true }, global })).classes()).toContain('max-w-3xl')
31
+ })
32
+ })
33
+
34
+ describe('BaseBooleanBadge', () => {
35
+ it('renders a green "Yes" pill for true and a neutral "No" pill for false', () => {
36
+ const yes = mount(BaseBooleanBadge, { props: { value: true } })
37
+ expect(yes.text()).toBe('Yes')
38
+ expect(yes.classes()).toContain('bg-emerald-50')
39
+ const no = mount(BaseBooleanBadge, { props: { value: false } })
40
+ expect(no.text()).toBe('No')
41
+ expect(no.classes()).toContain('bg-slate-100')
42
+ })
43
+
44
+ it('uses custom labels and treats null as false', () => {
45
+ const w = mount(BaseBooleanBadge, { props: { value: null, trueLabel: 'Verified', falseLabel: 'Not verified' } })
46
+ expect(w.text()).toBe('Not verified')
47
+ })
48
+ })
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <div class="flex min-h-screen w-full flex-col items-center justify-center bg-slate-50 px-4 py-10 dark:bg-slate-950">
3
+ <div class="mb-6 flex justify-center">
4
+ <slot name="logo">
5
+ <BaseLogo :size="BaseLogoEnum.MEDIUM" />
6
+ </slot>
7
+ </div>
8
+ <div
9
+ class="w-full rounded-xl border border-slate-200 bg-white p-6 text-left shadow-sm sm:p-8 dark:border-slate-700 dark:bg-slate-900"
10
+ :class="wide ? 'max-w-3xl' : 'max-w-md'"
11
+ >
12
+ <h1 v-if="title" class="mb-1 text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{{ title }}</h1>
13
+ <p v-if="subtitle" class="text-sm text-slate-500 dark:text-slate-400">{{ subtitle }}</p>
14
+ <div :class="title || subtitle ? 'mt-6' : ''">
15
+ <slot />
16
+ </div>
17
+ <div
18
+ v-if="$slots.footer"
19
+ class="mt-6 border-t border-slate-200 pt-4 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400"
20
+ >
21
+ <slot name="footer" />
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ /**
29
+ * Full-page, centred shell for sign-in / sign-up / password-recovery screens:
30
+ * logo on top (defaults to BaseLogo, override via the `logo` slot), then a
31
+ * bordered card with the title, the page content (default slot) and an
32
+ * optional `footer` ("Don't have an account? Sign up"). `wide` widens the card
33
+ * for multi-column forms such as registration.
34
+ */
35
+ import BaseLogo from './BaseLogo.vue'
36
+ import { BaseLogoEnum } from '../enums/BaseLogoEnum'
37
+
38
+ interface Props {
39
+ title?: string
40
+ subtitle?: string
41
+ /** max-w-3xl card instead of max-w-md (multi-column forms). */
42
+ wide?: boolean
43
+ }
44
+
45
+ withDefaults(defineProps<Props>(), {
46
+ title: '',
47
+ subtitle: '',
48
+ wide: false,
49
+ })
50
+ </script>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <span
3
+ class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
4
+ :class="value
5
+ ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/30'
6
+ : 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/50 dark:text-slate-300 dark:ring-slate-400/20'"
7
+ >
8
+ <CheckCircleIcon v-if="value" class="h-3.5 w-3.5" aria-hidden="true" />
9
+ <XCircleIcon v-else class="h-3.5 w-3.5" aria-hidden="true" />
10
+ {{ value ? trueLabel : falseLabel }}
11
+ </span>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ /**
16
+ * Yes/no status pill for boolean columns ("Verified", "Has API", …):
17
+ * a green check pill when true, a neutral cross pill when false — so a
18
+ * `false` reads as a state, not as an error.
19
+ */
20
+ import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
21
+
22
+ interface Props {
23
+ value?: boolean | null
24
+ trueLabel?: string
25
+ falseLabel?: string
26
+ }
27
+
28
+ withDefaults(defineProps<Props>(), {
29
+ value: false,
30
+ trueLabel: 'Yes',
31
+ falseLabel: 'No',
32
+ })
33
+ </script>
@@ -40,7 +40,7 @@ const props = defineProps({
40
40
  color: {
41
41
  type: String,
42
42
  required: false,
43
- default: BaseButtonEnum.BLUE
43
+ default: BaseButtonEnum.GREEN
44
44
  },
45
45
  to: {
46
46
  type: String,
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseDetailList from './BaseDetailList.vue'
4
+
5
+ describe('BaseDetailList', () => {
6
+ it('renders each label with its value', () => {
7
+ const wrapper = mount(BaseDetailList, {
8
+ props: { items: [{ label: 'Status', value: 'PUBLISHED' }, { label: 'Count', value: 3 }] },
9
+ })
10
+ expect(wrapper.findAll('dt').map((d) => d.text())).toEqual(['Status', 'Count'])
11
+ expect(wrapper.findAll('dd').map((d) => d.text())).toEqual(['PUBLISHED', '3'])
12
+ })
13
+
14
+ it('shows the empty text for missing values', () => {
15
+ const wrapper = mount(BaseDetailList, {
16
+ props: { items: [{ label: 'Link', value: null }, { label: 'Tags', value: '' }], emptyText: 'n/a' },
17
+ })
18
+ expect(wrapper.findAll('dd').map((d) => d.text())).toEqual(['n/a', 'n/a'])
19
+ })
20
+
21
+ it('renders href values as external links and applies mono', () => {
22
+ const wrapper = mount(BaseDetailList, {
23
+ props: { items: [{ label: 'Link', value: 'open', href: 'https://x.test' }, { label: 'Id', value: 'abc', mono: true }] },
24
+ })
25
+ const a = wrapper.find('a')
26
+ expect(a.attributes('href')).toBe('https://x.test')
27
+ expect(a.attributes('target')).toBe('_blank')
28
+ expect(wrapper.findAll('dd')[1].classes()).toContain('font-mono')
29
+ })
30
+
31
+ it('switches the grid columns', () => {
32
+ expect(mount(BaseDetailList, { props: { items: [], columns: 1 } }).classes()).not.toContain('sm:grid-cols-2')
33
+ expect(mount(BaseDetailList, { props: { items: [] } }).classes()).toContain('sm:grid-cols-2')
34
+ expect(mount(BaseDetailList, { props: { items: [], columns: 3 } }).classes()).toContain('lg:grid-cols-3')
35
+ })
36
+ })
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <dl class="grid grid-cols-1 gap-x-6 gap-y-4 text-left" :class="columns === 2 ? 'sm:grid-cols-2' : columns === 3 ? 'sm:grid-cols-2 lg:grid-cols-3' : ''">
3
+ <div v-for="item in items" :key="item.label" class="min-w-0">
4
+ <dt class="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{{ item.label }}</dt>
5
+ <dd class="mt-1 break-words text-sm text-slate-900 dark:text-slate-100" :class="item.mono ? 'font-mono' : ''">
6
+ <a
7
+ v-if="item.href && !isEmpty(item.value)"
8
+ :href="item.href"
9
+ target="_blank"
10
+ rel="noopener noreferrer"
11
+ class="text-emerald-600 hover:underline dark:text-emerald-400"
12
+ >{{ item.value }}</a>
13
+ <span v-else-if="isEmpty(item.value)" class="text-slate-400 dark:text-slate-500">{{ emptyText }}</span>
14
+ <template v-else>{{ item.value }}</template>
15
+ </dd>
16
+ </div>
17
+ </dl>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ /**
22
+ * Read-only label/value grid for detail pages — the "view" counterpart of a
23
+ * form. Empty values render `emptyText` (—); `href` makes the value an
24
+ * external link; `mono` sets it in the monospace face (ids, hashes).
25
+ */
26
+ import type { DetailItem } from '../types/detail'
27
+
28
+ interface Props {
29
+ items: readonly DetailItem[]
30
+ /** Columns from `sm` up (1 column on phones). */
31
+ columns?: 1 | 2 | 3
32
+ emptyText?: string
33
+ }
34
+
35
+ withDefaults(defineProps<Props>(), {
36
+ columns: 2,
37
+ emptyText: '—',
38
+ })
39
+
40
+ function isEmpty(v: DetailItem['value']): boolean {
41
+ return v === null || v === undefined || v === ''
42
+ }
43
+ </script>
@@ -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
 
@@ -11,7 +11,7 @@ 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-lg', 'border', 'w-full', 'pl-2.5', '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
 
@@ -3,7 +3,7 @@
3
3
  <select
4
4
  v-bind="$attrs"
5
5
  :value="modelValue"
6
- class="w-full appearance-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"
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
7
  :class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
8
8
  @change="onChange"
9
9
  >
@@ -28,7 +28,8 @@
28
28
  import { ChevronDownIcon } from '@heroicons/vue/24/outline'
29
29
 
30
30
  // Attrs (id, name, disabled, extra classes) go to the <select>, not the
31
- // chevron wrapper.
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.
32
33
  defineOptions({ inheritAttrs: false })
33
34
 
34
35
  // String-valued only — a DOM select always yields strings; use v-model.number
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseToggle from './BaseToggle.vue'
4
+
5
+ describe('BaseToggle', () => {
6
+ it('reflects the value in aria-checked and the track colour', () => {
7
+ const off = mount(BaseToggle, { props: { modelValue: false } })
8
+ expect(off.find('[role=switch]').attributes('aria-checked')).toBe('false')
9
+ expect(off.find('[role=switch]').classes()).toContain('bg-slate-200')
10
+
11
+ const on = mount(BaseToggle, { props: { modelValue: true, color: 'red' } })
12
+ expect(on.find('[role=switch]').attributes('aria-checked')).toBe('true')
13
+ expect(on.find('[role=switch]').classes()).toContain('bg-red-600')
14
+ })
15
+
16
+ it('uses the red off track with offTone="red"', () => {
17
+ const w = mount(BaseToggle, { props: { modelValue: false, offTone: 'red' } })
18
+ expect(w.find('[role=switch]').classes()).toContain('bg-red-200')
19
+ })
20
+
21
+ it('emits update:modelValue and change with the flipped value', async () => {
22
+ const w = mount(BaseToggle, { props: { modelValue: false } })
23
+ await w.find('[role=switch]').trigger('click')
24
+ expect(w.emitted('update:modelValue')).toEqual([[true]])
25
+ expect(w.emitted('change')).toEqual([[true]])
26
+ })
27
+
28
+ it('does nothing while disabled', async () => {
29
+ const w = mount(BaseToggle, { props: { modelValue: true, disabled: true } })
30
+ await w.find('[role=switch]').trigger('click')
31
+ expect(w.emitted('update:modelValue')).toBeUndefined()
32
+ })
33
+
34
+ it('renders the label, or uses ariaLabel when there is none', () => {
35
+ expect(mount(BaseToggle, { props: { label: 'Crawler' } }).text()).toBe('Crawler')
36
+ const w = mount(BaseToggle, { props: { ariaLabel: 'Alive' } })
37
+ expect(w.find('[role=switch]').attributes('aria-label')).toBe('Alive')
38
+ })
39
+ })
@@ -0,0 +1,73 @@
1
+ <template>
2
+ <label
3
+ class="inline-flex items-center gap-3 select-none"
4
+ :class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
5
+ >
6
+ <button
7
+ type="button"
8
+ role="switch"
9
+ :aria-checked="modelValue"
10
+ :aria-label="label ? undefined : ariaLabel || undefined"
11
+ :disabled="disabled"
12
+ class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900 disabled:cursor-not-allowed"
13
+ :class="modelValue ? ON[color] : offTone === 'red' ? 'bg-red-200 dark:bg-red-900/50' : 'bg-slate-200 dark:bg-slate-700'"
14
+ @click="toggle"
15
+ >
16
+ <span
17
+ class="inline-block h-5 w-5 rounded-full bg-white shadow ring-1 ring-black/5 transition-transform"
18
+ :class="modelValue ? 'translate-x-[22px]' : 'translate-x-0.5'"
19
+ aria-hidden="true"
20
+ />
21
+ </button>
22
+ <span v-if="label" class="text-sm font-medium text-slate-700 dark:text-slate-200">{{ label }}</span>
23
+ </label>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ /**
28
+ * On/off switch (role="switch"). `v-model` is the boolean; `change` fires
29
+ * after every user toggle with the new value, so callers can persist it.
30
+ * `color` tints the on state; `offTone="red"` shows a red track while off
31
+ * (e.g. "account is down" states).
32
+ */
33
+ type Color = 'emerald' | 'red' | 'sky' | 'amber'
34
+
35
+ interface Props {
36
+ modelValue?: boolean
37
+ /** Visible label beside the switch (also its accessible name). */
38
+ label?: string
39
+ /** Accessible name when there is no visible label. */
40
+ ariaLabel?: string
41
+ color?: Color
42
+ offTone?: 'slate' | 'red'
43
+ disabled?: boolean
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ modelValue: false,
48
+ label: '',
49
+ ariaLabel: '',
50
+ color: 'emerald',
51
+ offTone: 'slate',
52
+ disabled: false,
53
+ })
54
+
55
+ const emit = defineEmits<{
56
+ (e: 'update:modelValue', value: boolean): void
57
+ (e: 'change', value: boolean): void
58
+ }>()
59
+
60
+ const ON: Record<Color, string> = {
61
+ emerald: 'bg-emerald-500',
62
+ red: 'bg-red-600',
63
+ sky: 'bg-sky-500',
64
+ amber: 'bg-amber-500',
65
+ }
66
+
67
+ function toggle() {
68
+ if (props.disabled) return
69
+ const next = !props.modelValue
70
+ emit('update:modelValue', next)
71
+ emit('change', next)
72
+ }
73
+ </script>
@@ -1,85 +1,93 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { mount } from '@vue/test-utils'
3
- import Pagination from './Pagination.vue'
4
-
5
- /** Visible cell labels in DOM order ('…' for the ellipsis span). */
6
- function cellLabels(wrapper: ReturnType<typeof mount>): string[] {
7
- return wrapper.findAll('li').map(li => li.text().trim())
8
- }
9
-
10
- async function clickPage(wrapper: ReturnType<typeof mount>, page: number) {
11
- const link = wrapper.findAll('button').find(b => b.text().trim() === String(page))
12
- expect(link, `expected a visible cell for page ${page}`).toBeTruthy()
13
- await link!.trigger('click')
14
- }
15
-
16
- describe('Pagination', () => {
17
- it('renders every page without an ellipsis when totalPages <= 5', () => {
18
- const wrapper = mount(Pagination, {
19
- props: { totalItems: 100, itemsPerPage: 20 },
20
- })
21
- expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5'])
22
- })
23
-
24
- it('renders a window with a trailing ellipsis on the first page', () => {
25
- const wrapper = mount(Pagination, {
26
- props: { totalItems: 200, itemsPerPage: 20 },
27
- })
28
- expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '...', '10'])
29
- })
30
-
31
- it('keeps cells in ascending order around a middle page', async () => {
32
- const wrapper = mount(Pagination, {
33
- props: { totalItems: 200, itemsPerPage: 20 },
34
- })
35
- await clickPage(wrapper, 3)
36
- expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5', '...', '10'])
37
-
38
- await clickPage(wrapper, 5)
39
- expect(cellLabels(wrapper)).toEqual(['1', '...', '3', '4', '5', '6', '7', '...', '10'])
40
-
41
- expect(wrapper.emitted('page-changed')).toEqual([[3], [5]])
42
- })
43
-
44
- it('omits the ellipsis when no pages are hidden next to the edges', async () => {
45
- const wrapper = mount(Pagination, {
46
- props: { totalItems: 200, itemsPerPage: 20 },
47
- })
48
- await clickPage(wrapper, 3)
49
- await clickPage(wrapper, 4)
50
- // window is 2..6, so only page 1 precedes it — no gap, no ellipsis
51
- expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5', '6', '...', '10'])
52
- })
53
-
54
- it('renders the trailing window on the last page', async () => {
55
- const wrapper = mount(Pagination, {
56
- props: { totalItems: 200, itemsPerPage: 20 },
57
- })
58
- await clickPage(wrapper, 3)
59
- await clickPage(wrapper, 5)
60
- await clickPage(wrapper, 7)
61
- await clickPage(wrapper, 9)
62
- await clickPage(wrapper, 10)
63
- expect(cellLabels(wrapper)).toEqual(['1', '...', '8', '9', '10'])
64
- })
65
-
66
- it('does not emit when the active page is clicked again', async () => {
67
- const wrapper = mount(Pagination, {
68
- props: { totalItems: 200, itemsPerPage: 20 },
69
- })
70
- await clickPage(wrapper, 1)
71
- expect(wrapper.emitted('page-changed')).toBeUndefined()
72
- })
73
-
74
- it('clamps the current page when the item count shrinks', async () => {
75
- const wrapper = mount(Pagination, {
76
- props: { totalItems: 200, itemsPerPage: 20 },
77
- })
78
- await clickPage(wrapper, 3)
79
- await clickPage(wrapper, 5)
80
- await clickPage(wrapper, 7)
81
- await wrapper.setProps({ totalItems: 40 })
82
- expect(cellLabels(wrapper)).toEqual(['1', '2'])
83
- expect(wrapper.find('button.active').text()).toBe('2')
84
- })
85
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Pagination from './Pagination.vue'
4
+
5
+ /** Visible cell labels in DOM order ('…' for the ellipsis span). */
6
+ function cellLabels(wrapper: ReturnType<typeof mount>): string[] {
7
+ return wrapper.findAll('li').map(li => li.text().trim())
8
+ }
9
+
10
+ async function clickPage(wrapper: ReturnType<typeof mount>, page: number) {
11
+ const link = wrapper.findAll('button').find(b => b.text().trim() === String(page))
12
+ expect(link, `expected a visible cell for page ${page}`).toBeTruthy()
13
+ await link!.trigger('click')
14
+ }
15
+
16
+ describe('Pagination', () => {
17
+ it('renders every page without an ellipsis when totalPages <= 5', () => {
18
+ const wrapper = mount(Pagination, {
19
+ props: { totalItems: 100, itemsPerPage: 20 },
20
+ })
21
+ expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5'])
22
+ })
23
+
24
+ it('renders a window with a trailing ellipsis on the first page', () => {
25
+ const wrapper = mount(Pagination, {
26
+ props: { totalItems: 200, itemsPerPage: 20 },
27
+ })
28
+ expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '...', '10'])
29
+ })
30
+
31
+ it('keeps cells in ascending order around a middle page', async () => {
32
+ const wrapper = mount(Pagination, {
33
+ props: { totalItems: 200, itemsPerPage: 20 },
34
+ })
35
+ await clickPage(wrapper, 3)
36
+ expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5', '...', '10'])
37
+
38
+ await clickPage(wrapper, 5)
39
+ expect(cellLabels(wrapper)).toEqual(['1', '...', '3', '4', '5', '6', '7', '...', '10'])
40
+
41
+ expect(wrapper.emitted('page-changed')).toEqual([[3], [5]])
42
+ })
43
+
44
+ it('omits the ellipsis when no pages are hidden next to the edges', async () => {
45
+ const wrapper = mount(Pagination, {
46
+ props: { totalItems: 200, itemsPerPage: 20 },
47
+ })
48
+ await clickPage(wrapper, 3)
49
+ await clickPage(wrapper, 4)
50
+ // window is 2..6, so only page 1 precedes it — no gap, no ellipsis
51
+ expect(cellLabels(wrapper)).toEqual(['1', '2', '3', '4', '5', '6', '...', '10'])
52
+ })
53
+
54
+ it('renders the trailing window on the last page', async () => {
55
+ const wrapper = mount(Pagination, {
56
+ props: { totalItems: 200, itemsPerPage: 20 },
57
+ })
58
+ await clickPage(wrapper, 3)
59
+ await clickPage(wrapper, 5)
60
+ await clickPage(wrapper, 7)
61
+ await clickPage(wrapper, 9)
62
+ await clickPage(wrapper, 10)
63
+ expect(cellLabels(wrapper)).toEqual(['1', '...', '8', '9', '10'])
64
+ })
65
+
66
+ it('does not emit when the active page is clicked again', async () => {
67
+ const wrapper = mount(Pagination, {
68
+ props: { totalItems: 200, itemsPerPage: 20 },
69
+ })
70
+ await clickPage(wrapper, 1)
71
+ expect(wrapper.emitted('page-changed')).toBeUndefined()
72
+ })
73
+
74
+ it('clamps the current page when the item count shrinks', async () => {
75
+ const wrapper = mount(Pagination, {
76
+ props: { totalItems: 200, itemsPerPage: 20 },
77
+ })
78
+ await clickPage(wrapper, 3)
79
+ await clickPage(wrapper, 5)
80
+ await clickPage(wrapper, 7)
81
+ await wrapper.setProps({ totalItems: 40 })
82
+ expect(cellLabels(wrapper)).toEqual(['1', '2'])
83
+ expect(wrapper.find('button.active').text()).toBe('2')
84
+ })
85
+
86
+ it('falls back to 20 per page when itemsPerPage is null or 0 (no "Infinity")', () => {
87
+ for (const itemsPerPage of [null, 0]) {
88
+ const wrapper = mount(Pagination, { props: { totalItems: 16, itemsPerPage: itemsPerPage as unknown as number } })
89
+ expect(wrapper.text()).not.toContain('Infinity')
90
+ expect(wrapper.findAll('button').map((b) => b.text()).filter((t) => /^\d+$/.test(t))).toEqual(['1'])
91
+ }
92
+ })
93
+ })