mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 19:04:46 +00:00
feat(vue/primitives): implement pagination components with accessibility and testing
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export * from './primitive';
|
||||
export * from './pagination';
|
||||
|
||||
24
vue/primitives/src/pagination/PaginationEllipsis.vue
Normal file
24
vue/primitives/src/pagination/PaginationEllipsis.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationEllipsisProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
|
||||
const { as = 'span' as const } = defineProps<PaginationEllipsisProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
data-type="ellipsis"
|
||||
>
|
||||
<slot>…</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
42
vue/primitives/src/pagination/PaginationFirst.vue
Normal file
42
vue/primitives/src/pagination/PaginationFirst.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationFirstProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { injectPaginationContext } from './context';
|
||||
|
||||
const { as = 'button' as const } = defineProps<PaginationFirstProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = injectPaginationContext();
|
||||
|
||||
const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
|
||||
|
||||
const attrs = computed(() => ({
|
||||
'aria-label': 'First Page',
|
||||
'type': as === 'button' ? 'button' as const : undefined,
|
||||
'disabled': disabled.value,
|
||||
}));
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled.value) {
|
||||
ctx.onPageChange(1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
v-bind="attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot>First page</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
42
vue/primitives/src/pagination/PaginationLast.vue
Normal file
42
vue/primitives/src/pagination/PaginationLast.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationLastProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { injectPaginationContext } from './context';
|
||||
|
||||
const { as = 'button' as const } = defineProps<PaginationLastProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = injectPaginationContext();
|
||||
|
||||
const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
|
||||
|
||||
const attrs = computed(() => ({
|
||||
'aria-label': 'Last Page',
|
||||
'type': as === 'button' ? 'button' as const : undefined,
|
||||
'disabled': disabled.value,
|
||||
}));
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled.value) {
|
||||
ctx.onPageChange(ctx.totalPages.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
v-bind="attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot>Last page</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
41
vue/primitives/src/pagination/PaginationList.vue
Normal file
41
vue/primitives/src/pagination/PaginationList.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationListProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { injectPaginationContext } from './context';
|
||||
import { getRange } from './utils';
|
||||
import type { PaginationItem } from './utils';
|
||||
|
||||
const { as = 'div' as const } = defineProps<PaginationListProps>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
items: PaginationItem[];
|
||||
}) => any;
|
||||
}>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = injectPaginationContext();
|
||||
|
||||
const items = computed<PaginationItem[]>(() => getRange(
|
||||
ctx.currentPage.value,
|
||||
ctx.totalPages.value,
|
||||
ctx.siblingCount.value,
|
||||
ctx.showEdges.value,
|
||||
));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as
|
||||
:ref="forwardRef"
|
||||
>
|
||||
<slot :items="items" />
|
||||
</Primitive>
|
||||
</template>
|
||||
48
vue/primitives/src/pagination/PaginationListItem.vue
Normal file
48
vue/primitives/src/pagination/PaginationListItem.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationListItemProps extends PrimitiveProps {
|
||||
value: number;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { injectPaginationContext } from './context';
|
||||
|
||||
const { as = 'button' as const, value } = defineProps<PaginationListItemProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = injectPaginationContext();
|
||||
|
||||
const isSelected = computed(() => ctx.currentPage.value === value);
|
||||
const disabled = computed(() => ctx.disabled.value);
|
||||
|
||||
const attrs = computed(() => ({
|
||||
'data-type': 'page',
|
||||
'aria-label': `Page ${value}`,
|
||||
'aria-current': isSelected.value ? 'page' as const : undefined,
|
||||
'data-selected': isSelected.value ? 'true' : undefined,
|
||||
'disabled': disabled.value,
|
||||
'type': as === 'button' ? 'button' as const : undefined,
|
||||
}));
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled.value) {
|
||||
ctx.onPageChange(value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
v-bind="attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot>{{ value }}</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
42
vue/primitives/src/pagination/PaginationNext.vue
Normal file
42
vue/primitives/src/pagination/PaginationNext.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationNextProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { injectPaginationContext } from './context';
|
||||
|
||||
const { as = 'button' as const } = defineProps<PaginationNextProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = injectPaginationContext();
|
||||
|
||||
const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
|
||||
|
||||
const attrs = computed(() => ({
|
||||
'aria-label': 'Next Page',
|
||||
'type': as === 'button' ? 'button' as const : undefined,
|
||||
'disabled': disabled.value,
|
||||
}));
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled.value) {
|
||||
ctx.onPageChange(ctx.currentPage.value + 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
v-bind="attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot>Next page</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
42
vue/primitives/src/pagination/PaginationPrev.vue
Normal file
42
vue/primitives/src/pagination/PaginationPrev.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationPrevProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { injectPaginationContext } from './context';
|
||||
|
||||
const { as = 'button' as const } = defineProps<PaginationPrevProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const ctx = injectPaginationContext();
|
||||
|
||||
const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
|
||||
|
||||
const attrs = computed(() => ({
|
||||
'aria-label': 'Previous Page',
|
||||
'type': as === 'button' ? 'button' as const : undefined,
|
||||
'disabled': disabled.value,
|
||||
}));
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled.value) {
|
||||
ctx.onPageChange(ctx.currentPage.value - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
v-bind="attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot>Prev page</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
89
vue/primitives/src/pagination/PaginationRoot.vue
Normal file
89
vue/primitives/src/pagination/PaginationRoot.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '@/primitive';
|
||||
|
||||
export interface PaginationRootProps extends PrimitiveProps {
|
||||
total: number;
|
||||
pageSize?: number;
|
||||
siblingCount?: number;
|
||||
showEdges?: boolean;
|
||||
disabled?: boolean;
|
||||
defaultPage?: number;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue';
|
||||
import { useOffsetPagination, useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { providePaginationContext } from './context';
|
||||
|
||||
const {
|
||||
as = 'nav' as const,
|
||||
total,
|
||||
pageSize = 10,
|
||||
siblingCount = 1,
|
||||
showEdges = false,
|
||||
disabled = false,
|
||||
defaultPage = 1,
|
||||
} = defineProps<PaginationRootProps>();
|
||||
|
||||
const page = defineModel<number>('page', { default: undefined });
|
||||
|
||||
if (page.value === undefined) {
|
||||
page.value = defaultPage;
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
}) => any;
|
||||
}>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
totalPages,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
next,
|
||||
previous,
|
||||
select,
|
||||
} = useOffsetPagination({
|
||||
total: () => total,
|
||||
page,
|
||||
pageSize: toRef(() => pageSize),
|
||||
});
|
||||
|
||||
function onPageChange(value: number) {
|
||||
page.value = value;
|
||||
}
|
||||
|
||||
providePaginationContext({
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize: toRef(() => pageSize),
|
||||
siblingCount: toRef(() => siblingCount),
|
||||
showEdges: toRef(() => showEdges),
|
||||
disabled: toRef(() => disabled),
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
onPageChange,
|
||||
next,
|
||||
prev: previous,
|
||||
select,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as
|
||||
>
|
||||
<slot
|
||||
:page="page!"
|
||||
:page-count="totalPages"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
394
vue/primitives/src/pagination/__test__/Pagination.test.ts
Normal file
394
vue/primitives/src/pagination/__test__/Pagination.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import {
|
||||
PaginationRoot,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationFirst,
|
||||
PaginationPrev,
|
||||
PaginationNext,
|
||||
PaginationLast,
|
||||
PaginationEllipsis,
|
||||
} from '..';
|
||||
import type { PaginationItem } from '../utils';
|
||||
|
||||
function createPagination(props: Record<string, unknown> = {}) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const page = ref((props.page as number) ?? 1);
|
||||
|
||||
return () =>
|
||||
h(
|
||||
PaginationRoot,
|
||||
{
|
||||
'total': 100,
|
||||
'pageSize': 10,
|
||||
...props,
|
||||
'page': page.value,
|
||||
'onUpdate:page': (v: number) => {
|
||||
page.value = v;
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(PaginationList, null, {
|
||||
default: ({ items }: { items: PaginationItem[] }) =>
|
||||
items.map((item, i) =>
|
||||
item.type === 'page'
|
||||
? h(PaginationListItem, { key: i, value: item.value })
|
||||
: h(PaginationEllipsis, { key: `ellipsis-${i}` }),
|
||||
),
|
||||
}),
|
||||
h(PaginationFirst),
|
||||
h(PaginationPrev),
|
||||
h(PaginationNext),
|
||||
h(PaginationLast),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('PaginationRoot', () => {
|
||||
it('renders as <nav> by default', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('nav').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders as custom element via as prop', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
PaginationRoot,
|
||||
{ total: 50, pageSize: 10, as: 'div' },
|
||||
{ default: () => h('span', 'content') },
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.find('div').exists()).toBe(true);
|
||||
expect(wrapper.find('nav').exists()).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('uses defaultPage when no v-model page is provided', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
PaginationRoot,
|
||||
{ total: 100, pageSize: 10, defaultPage: 5 },
|
||||
{
|
||||
default: () =>
|
||||
h(PaginationList, null, {
|
||||
default: ({ items }: { items: PaginationItem[] }) =>
|
||||
items.map((item, i) =>
|
||||
item.type === 'page'
|
||||
? h(PaginationListItem, { key: i, value: item.value })
|
||||
: h(PaginationEllipsis, { key: `e-${i}` }),
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const selected = wrapper.find('[data-selected]');
|
||||
expect(selected.exists()).toBe(true);
|
||||
expect(selected.text()).toBe('5');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes page and pageCount via scoped slot', () => {
|
||||
let slotPage = 0;
|
||||
let slotPageCount = 0;
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
PaginationRoot,
|
||||
{ total: 100, pageSize: 10, page: 3 },
|
||||
{
|
||||
default: ({ page, pageCount }: { page: number; pageCount: number }) => {
|
||||
slotPage = page;
|
||||
slotPageCount = pageCount;
|
||||
return h('span', `${page}/${pageCount}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(slotPage).toBe(3);
|
||||
expect(slotPageCount).toBe(10);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationList', () => {
|
||||
it('exposes items via scoped slot', () => {
|
||||
let capturedItems: PaginationItem[] = [];
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
PaginationRoot,
|
||||
{ total: 50, pageSize: 10 },
|
||||
{
|
||||
default: () =>
|
||||
h(PaginationList, null, {
|
||||
default: ({ items }: { items: PaginationItem[] }) => {
|
||||
capturedItems = items;
|
||||
|
||||
return items.map((item, i) =>
|
||||
item.type === 'page'
|
||||
? h('span', { key: i }, String(item.value))
|
||||
: h('span', { key: `e-${i}` }, '...'),
|
||||
);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedItems.length).toBeGreaterThan(0);
|
||||
expect(capturedItems.every(i => i.type === 'page' || i.type === 'ellipsis')).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationListItem', () => {
|
||||
it('renders as button by default', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
const pageButtons = wrapper.findAll('[data-type="page"]');
|
||||
expect(pageButtons.length).toBeGreaterThan(0);
|
||||
expect(pageButtons[0]!.element.tagName).toBe('BUTTON');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('marks current page with data-selected', () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
const selected = wrapper.find('[data-selected]');
|
||||
expect(selected.exists()).toBe(true);
|
||||
expect(selected.text()).toBe('1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders page number as default slot', () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
const pageButtons = wrapper.findAll('[data-type="page"]');
|
||||
pageButtons.forEach((btn) => {
|
||||
expect(Number(btn.text())).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('navigates on click', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
const page2 = wrapper.findAll('[data-type="page"]').find(el => el.text() === '2');
|
||||
await page2?.trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('2');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not navigate when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 1, disabled: true });
|
||||
|
||||
const page2 = wrapper.findAll('[data-type="page"]').find(el => el.text() === '2');
|
||||
await page2?.trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationFirst', () => {
|
||||
it('navigates to first page on click', async () => {
|
||||
const wrapper = createPagination({ page: 5 });
|
||||
|
||||
await wrapper.find('[aria-label="First Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled on first page', () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
expect(wrapper.find('[aria-label="First Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not navigate when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 5, disabled: true });
|
||||
|
||||
await wrapper.find('[aria-label="First Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('5');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationPrev', () => {
|
||||
it('navigates to previous page on click', async () => {
|
||||
const wrapper = createPagination({ page: 3 });
|
||||
|
||||
await wrapper.find('[aria-label="Previous Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('2');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled on first page', () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not navigate when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 5, disabled: true });
|
||||
|
||||
await wrapper.find('[aria-label="Previous Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('5');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationNext', () => {
|
||||
it('navigates to next page on click', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
await wrapper.find('[aria-label="Next Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('2');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled on last page', () => {
|
||||
const wrapper = createPagination({ page: 10 });
|
||||
|
||||
expect(wrapper.find('[aria-label="Next Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not navigate when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 1, disabled: true });
|
||||
|
||||
await wrapper.find('[aria-label="Next Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationLast', () => {
|
||||
it('navigates to last page on click', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
await wrapper.find('[aria-label="Last Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('10');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled on last page', () => {
|
||||
const wrapper = createPagination({ page: 10 });
|
||||
|
||||
expect(wrapper.find('[aria-label="Last Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not navigate when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 1, disabled: true });
|
||||
|
||||
await wrapper.find('[aria-label="Last Page"]').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-selected]').text()).toBe('1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationEllipsis', () => {
|
||||
it('renders for large page ranges', () => {
|
||||
const wrapper = createPagination({ page: 5, total: 200, pageSize: 10 });
|
||||
|
||||
expect(wrapper.find('[data-type="ellipsis"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders as <span> by default', () => {
|
||||
const wrapper = createPagination({ page: 5, total: 200, pageSize: 10 });
|
||||
|
||||
const ellipsis = wrapper.find('[data-type="ellipsis"]');
|
||||
expect(ellipsis.element.tagName).toBe('SPAN');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders \u2026 as default content', () => {
|
||||
const wrapper = createPagination({ page: 5, total: 200, pageSize: 10 });
|
||||
|
||||
const ellipsis = wrapper.find('[data-type="ellipsis"]');
|
||||
expect(ellipsis.text()).toBe('\u2026');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
429
vue/primitives/src/pagination/__test__/a11y.test.ts
Normal file
429
vue/primitives/src/pagination/__test__/a11y.test.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import axe from 'axe-core';
|
||||
import {
|
||||
PaginationRoot,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationFirst,
|
||||
PaginationPrev,
|
||||
PaginationNext,
|
||||
PaginationLast,
|
||||
PaginationEllipsis,
|
||||
} from '..';
|
||||
import type { PaginationItem } from '../utils';
|
||||
|
||||
async function checkA11y(element: Element) {
|
||||
const results = await axe.run(element);
|
||||
|
||||
return results.violations;
|
||||
}
|
||||
|
||||
function createPagination(props: Record<string, unknown> = {}) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const page = ref((props.page as number) ?? 1);
|
||||
|
||||
return () =>
|
||||
h(
|
||||
PaginationRoot,
|
||||
{
|
||||
'total': 100,
|
||||
'pageSize': 10,
|
||||
...props,
|
||||
'page': page.value,
|
||||
'onUpdate:page': (v: number) => {
|
||||
page.value = v;
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(PaginationList, null, {
|
||||
default: ({ items }: { items: PaginationItem[] }) =>
|
||||
items.map((item, i) =>
|
||||
item.type === 'page'
|
||||
? h(PaginationListItem, { key: i, value: item.value })
|
||||
: h(PaginationEllipsis, { key: `ellipsis-${i}` }),
|
||||
),
|
||||
}),
|
||||
h(PaginationFirst),
|
||||
h(PaginationPrev),
|
||||
h(PaginationNext),
|
||||
h(PaginationLast),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
describe('PaginationListItem a11y', () => {
|
||||
it('has data-type="page"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.findAll('[data-type="page"]').length).toBeGreaterThan(0);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has aria-label with page number', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
const pageButton = wrapper.find('[data-type="page"]');
|
||||
expect(pageButton.attributes('aria-label')).toMatch(/^Page \d+$/);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has aria-current="page" only on selected page', () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
|
||||
const selected = wrapper.find('[aria-current="page"]');
|
||||
expect(selected.exists()).toBe(true);
|
||||
expect(selected.text()).toBe('1');
|
||||
|
||||
const nonSelected = wrapper.findAll('[data-type="page"]').filter(el => el.text() !== '1');
|
||||
nonSelected.forEach((el) => {
|
||||
expect(el.attributes('aria-current')).toBeUndefined();
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has type="button"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
wrapper.findAll('[data-type="page"]').forEach((btn) => {
|
||||
expect(btn.attributes('type')).toBe('button');
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled when context disabled', () => {
|
||||
const wrapper = createPagination({ disabled: true });
|
||||
|
||||
wrapper.findAll('[data-type="page"]').forEach((btn) => {
|
||||
expect(btn.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when selected', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const violations = await checkA11y(wrapper.find('[data-selected="true"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when not selected', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const nonSelected = wrapper.findAll('[data-type="page"]').find(el => el.text() !== '1');
|
||||
const violations = await checkA11y(nonSelected!.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 1, disabled: true });
|
||||
const violations = await checkA11y(wrapper.find('[data-type="page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationFirst a11y', () => {
|
||||
it('has aria-label="First Page"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="First Page"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has type="button"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="First Page"]').attributes('type')).toBe('button');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders default slot text', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="First Page"]').text()).toBe('First page');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled when context disabled', () => {
|
||||
const wrapper = createPagination({ page: 5, disabled: true });
|
||||
|
||||
expect(wrapper.find('[aria-label="First Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when enabled', async () => {
|
||||
const wrapper = createPagination({ page: 5 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="First Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="First Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationPrev a11y', () => {
|
||||
it('has aria-label="Previous Page"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous Page"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has type="button"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous Page"]').attributes('type')).toBe('button');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders default slot text', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous Page"]').text()).toBe('Prev page');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled when context disabled', () => {
|
||||
const wrapper = createPagination({ page: 5, disabled: true });
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when enabled', async () => {
|
||||
const wrapper = createPagination({ page: 5 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="Previous Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="Previous Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationNext a11y', () => {
|
||||
it('has aria-label="Next Page"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Next Page"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has type="button"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Next Page"]').attributes('type')).toBe('button');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders default slot text', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Next Page"]').text()).toBe('Next page');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled when context disabled', () => {
|
||||
const wrapper = createPagination({ page: 1, disabled: true });
|
||||
|
||||
expect(wrapper.find('[aria-label="Next Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when enabled', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="Next Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 10 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="Next Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationLast a11y', () => {
|
||||
it('has aria-label="Last Page"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Last Page"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has type="button"', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Last Page"]').attributes('type')).toBe('button');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders default slot text', () => {
|
||||
const wrapper = createPagination();
|
||||
|
||||
expect(wrapper.find('[aria-label="Last Page"]').text()).toBe('Last page');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is disabled when context disabled', () => {
|
||||
const wrapper = createPagination({ page: 1, disabled: true });
|
||||
|
||||
expect(wrapper.find('[aria-label="Last Page"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when enabled', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="Last Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when disabled', async () => {
|
||||
const wrapper = createPagination({ page: 10 });
|
||||
const violations = await checkA11y(wrapper.find('[aria-label="Last Page"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaginationEllipsis a11y', () => {
|
||||
it('is non-interactive (no button role)', () => {
|
||||
const wrapper = createPagination({ page: 5, total: 200, pageSize: 10 });
|
||||
|
||||
const ellipsis = wrapper.find('[data-type="ellipsis"]');
|
||||
expect(ellipsis.attributes('type')).toBeUndefined();
|
||||
expect(ellipsis.attributes('role')).toBeUndefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations', async () => {
|
||||
const wrapper = createPagination({ page: 5, total: 200, pageSize: 10 });
|
||||
const violations = await checkA11y(wrapper.find('[data-type="ellipsis"]').element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination composed a11y', () => {
|
||||
it('has no axe violations on first page', async () => {
|
||||
const wrapper = createPagination({ page: 1 });
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations on middle page', async () => {
|
||||
const wrapper = createPagination({ page: 5 });
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations on last page', async () => {
|
||||
const wrapper = createPagination({ page: 10 });
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations with many pages', async () => {
|
||||
const wrapper = createPagination({ page: 10, total: 500, pageSize: 10 });
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations when fully disabled', async () => {
|
||||
const wrapper = createPagination({ page: 5, disabled: true });
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations with showEdges', async () => {
|
||||
const wrapper = createPagination({ page: 5, total: 200, pageSize: 10, showEdges: true });
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
145
vue/primitives/src/pagination/__test__/utils.test.ts
Normal file
145
vue/primitives/src/pagination/__test__/utils.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getRange, transform, PaginationItemType } from '../utils';
|
||||
|
||||
describe(getRange, () => {
|
||||
it('returns empty array for zero total pages', () => {
|
||||
expect(getRange(1, 0, 1, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single page when totalPages is 1', () => {
|
||||
expect(getRange(1, 1, 1, false)).toEqual([
|
||||
{ type: 'page', value: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all pages when totalPages fits within visible window', () => {
|
||||
expect(getRange(1, 5, 1, false)).toEqual([
|
||||
{ type: 'page', value: 1 },
|
||||
{ type: 'page', value: 2 },
|
||||
{ type: 'page', value: 3 },
|
||||
{ type: 'page', value: 4 },
|
||||
{ type: 'page', value: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all pages when totalPages equals the threshold', () => {
|
||||
// siblingCount=1: totalWithEllipsis = 1*2+3+2 = 7
|
||||
expect(getRange(1, 7, 1, false)).toEqual([
|
||||
{ type: 'page', value: 1 },
|
||||
{ type: 'page', value: 2 },
|
||||
{ type: 'page', value: 3 },
|
||||
{ type: 'page', value: 4 },
|
||||
{ type: 'page', value: 5 },
|
||||
{ type: 'page', value: 6 },
|
||||
{ type: 'page', value: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows right ellipsis when current page is near the start', () => {
|
||||
const items = getRange(1, 10, 1, false);
|
||||
|
||||
expect(items[0]).toEqual({ type: 'page', value: 1 });
|
||||
expect(items).toContainEqual({ type: 'ellipsis' });
|
||||
expect(items[items.length - 1]).toEqual({ type: 'page', value: 10 });
|
||||
});
|
||||
|
||||
it('shows left ellipsis when current page is near the end', () => {
|
||||
const items = getRange(10, 10, 1, false);
|
||||
|
||||
expect(items[0]).toEqual({ type: 'page', value: 1 });
|
||||
expect(items).toContainEqual({ type: 'ellipsis' });
|
||||
expect(items[items.length - 1]).toEqual({ type: 'page', value: 10 });
|
||||
});
|
||||
|
||||
it('shows both ellipses when current page is in the middle', () => {
|
||||
const items = getRange(5, 10, 1, false);
|
||||
const ellipses = items.filter(i => i.type === 'ellipsis');
|
||||
|
||||
expect(ellipses).toHaveLength(2);
|
||||
expect(items[0]).toEqual({ type: 'page', value: 1 });
|
||||
expect(items[items.length - 1]).toEqual({ type: 'page', value: 10 });
|
||||
// Should include current page and siblings
|
||||
expect(items).toContainEqual({ type: 'page', value: 4 });
|
||||
expect(items).toContainEqual({ type: 'page', value: 5 });
|
||||
expect(items).toContainEqual({ type: 'page', value: 6 });
|
||||
});
|
||||
|
||||
it('respects siblingCount when generating range', () => {
|
||||
const items = getRange(10, 20, 2, false);
|
||||
const ellipses = items.filter(i => i.type === 'ellipsis');
|
||||
|
||||
expect(ellipses).toHaveLength(2);
|
||||
// Current page ± 2 siblings
|
||||
expect(items).toContainEqual({ type: 'page', value: 8 });
|
||||
expect(items).toContainEqual({ type: 'page', value: 9 });
|
||||
expect(items).toContainEqual({ type: 'page', value: 10 });
|
||||
expect(items).toContainEqual({ type: 'page', value: 11 });
|
||||
expect(items).toContainEqual({ type: 'page', value: 12 });
|
||||
});
|
||||
|
||||
it('shows edge pages when showEdges is true', () => {
|
||||
const items = getRange(5, 10, 1, true);
|
||||
|
||||
// First and last pages should always be present
|
||||
expect(items[0]).toEqual({ type: 'page', value: 1 });
|
||||
expect(items[items.length - 1]).toEqual({ type: 'page', value: 10 });
|
||||
|
||||
// Should have ellipses
|
||||
const ellipses = items.filter(i => i.type === 'ellipsis');
|
||||
expect(ellipses.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not duplicate first/last page with showEdges at boundaries', () => {
|
||||
const items = getRange(1, 10, 1, true);
|
||||
const firstPages = items.filter(i => i.type === 'page' && i.value === 1);
|
||||
|
||||
expect(firstPages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles large siblingCount gracefully', () => {
|
||||
const items = getRange(1, 3, 10, false);
|
||||
|
||||
expect(items).toEqual([
|
||||
{ type: 'page', value: 1 },
|
||||
{ type: 'page', value: 2 },
|
||||
{ type: 'page', value: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('always includes current page in the result', () => {
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const items = getRange(page, 20, 1, false);
|
||||
const pages = items.filter(i => i.type === 'page').map(i => (i as { type: 'page'; value: number }).value);
|
||||
|
||||
expect(pages).toContain(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(transform, () => {
|
||||
it('converts numbers to page items', () => {
|
||||
expect(transform([1, 2, 3])).toEqual([
|
||||
{ type: PaginationItemType.Page, value: 1 },
|
||||
{ type: PaginationItemType.Page, value: 2 },
|
||||
{ type: PaginationItemType.Page, value: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts strings to ellipsis items', () => {
|
||||
expect(transform(['...'])).toEqual([
|
||||
{ type: PaginationItemType.Ellipsis },
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts mixed array', () => {
|
||||
expect(transform([1, '...', 5])).toEqual([
|
||||
{ type: PaginationItemType.Page, value: 1 },
|
||||
{ type: PaginationItemType.Ellipsis },
|
||||
{ type: PaginationItemType.Page, value: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(transform([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
22
vue/primitives/src/pagination/context.ts
Normal file
22
vue/primitives/src/pagination/context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export interface PaginationContext {
|
||||
currentPage: Ref<number>;
|
||||
totalPages: ComputedRef<number>;
|
||||
pageSize: Ref<number>;
|
||||
siblingCount: Ref<number>;
|
||||
showEdges: Ref<boolean>;
|
||||
disabled: Ref<boolean>;
|
||||
isFirstPage: ComputedRef<boolean>;
|
||||
isLastPage: ComputedRef<boolean>;
|
||||
onPageChange: (value: number) => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
select: (page: number) => void;
|
||||
}
|
||||
|
||||
export const PaginationCtx = useContextFactory<PaginationContext>('PaginationContext');
|
||||
|
||||
export const providePaginationContext = PaginationCtx.provide;
|
||||
export const injectPaginationContext = PaginationCtx.inject;
|
||||
20
vue/primitives/src/pagination/index.ts
Normal file
20
vue/primitives/src/pagination/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { default as PaginationRoot } from './PaginationRoot.vue';
|
||||
export { default as PaginationList } from './PaginationList.vue';
|
||||
export { default as PaginationListItem } from './PaginationListItem.vue';
|
||||
export { default as PaginationEllipsis } from './PaginationEllipsis.vue';
|
||||
export { default as PaginationFirst } from './PaginationFirst.vue';
|
||||
export { default as PaginationLast } from './PaginationLast.vue';
|
||||
export { default as PaginationPrev } from './PaginationPrev.vue';
|
||||
export { default as PaginationNext } from './PaginationNext.vue';
|
||||
|
||||
export { PaginationCtx, type PaginationContext } from './context';
|
||||
export { PaginationItemType, getRange, transform, type PaginationItem, type Pages } from './utils';
|
||||
|
||||
export type { PaginationRootProps } from './PaginationRoot.vue';
|
||||
export type { PaginationListProps } from './PaginationList.vue';
|
||||
export type { PaginationListItemProps } from './PaginationListItem.vue';
|
||||
export type { PaginationEllipsisProps } from './PaginationEllipsis.vue';
|
||||
export type { PaginationFirstProps } from './PaginationFirst.vue';
|
||||
export type { PaginationLastProps } from './PaginationLast.vue';
|
||||
export type { PaginationPrevProps } from './PaginationPrev.vue';
|
||||
export type { PaginationNextProps } from './PaginationNext.vue';
|
||||
90
vue/primitives/src/pagination/utils.ts
Normal file
90
vue/primitives/src/pagination/utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export enum PaginationItemType {
|
||||
Page = 'page',
|
||||
Ellipsis = 'ellipsis',
|
||||
}
|
||||
|
||||
export type PaginationItem
|
||||
= | { type: 'page'; value: number }
|
||||
| { type: 'ellipsis' };
|
||||
|
||||
export type Pages = PaginationItem[];
|
||||
|
||||
export function transform(items: Array<string | number>): Pages {
|
||||
return items.map((value) => {
|
||||
if (typeof value === 'number')
|
||||
return { type: PaginationItemType.Page, value };
|
||||
return { type: PaginationItemType.Ellipsis };
|
||||
});
|
||||
}
|
||||
|
||||
const ELLIPSIS: PaginationItem = { type: 'ellipsis' };
|
||||
|
||||
function page(value: number): PaginationItem {
|
||||
return { type: 'page', value };
|
||||
}
|
||||
|
||||
function range(start: number, end: number): PaginationItem[] {
|
||||
const items: PaginationItem[] = [];
|
||||
|
||||
for (let i = start; i <= end; i++)
|
||||
items.push(page(i));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getRange(
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
siblingCount: number,
|
||||
showEdges: boolean,
|
||||
): PaginationItem[] {
|
||||
if (totalPages <= 0)
|
||||
return [];
|
||||
|
||||
// If total pages fit within the visible window, show all pages
|
||||
const totalVisible = siblingCount * 2 + 3; // siblings + current + 2 edges
|
||||
const totalWithEllipsis = totalVisible + 2; // + 2 ellipsis slots
|
||||
|
||||
if (totalPages <= totalWithEllipsis)
|
||||
return range(1, totalPages);
|
||||
|
||||
const leftSiblingStart = Math.max(currentPage - siblingCount, 1);
|
||||
const rightSiblingEnd = Math.min(currentPage + siblingCount, totalPages);
|
||||
|
||||
const leftEdgeOffset = showEdges ? 1 : 0;
|
||||
const showLeftEllipsis = leftSiblingStart > 2 + leftEdgeOffset;
|
||||
const showRightEllipsis = rightSiblingEnd < totalPages - 1 - leftEdgeOffset;
|
||||
|
||||
const items: PaginationItem[] = [];
|
||||
|
||||
// Always show first page (either as edge or as part of the sequence)
|
||||
items.push(page(1));
|
||||
|
||||
if (showLeftEllipsis) {
|
||||
items.push(ELLIPSIS);
|
||||
}
|
||||
else {
|
||||
// Show all pages from 2 to leftSiblingStart - 1
|
||||
for (let i = 2; i < leftSiblingStart; i++)
|
||||
items.push(page(i));
|
||||
}
|
||||
|
||||
// Sibling pages including current (skip 1 since already added)
|
||||
for (let i = Math.max(leftSiblingStart, 2); i <= Math.min(rightSiblingEnd, totalPages - 1); i++)
|
||||
items.push(page(i));
|
||||
|
||||
if (showRightEllipsis) {
|
||||
items.push(ELLIPSIS);
|
||||
}
|
||||
else {
|
||||
// Show all pages from rightSiblingEnd + 1 to totalPages - 1
|
||||
for (let i = rightSiblingEnd + 1; i < totalPages; i++)
|
||||
items.push(page(i));
|
||||
}
|
||||
|
||||
// Always show last page (if more than 1 page)
|
||||
if (totalPages > 1)
|
||||
items.push(page(totalPages));
|
||||
|
||||
return items;
|
||||
}
|
||||
Reference in New Issue
Block a user