1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

feat(vue/primitives): implement pagination components with accessibility and testing

This commit is contained in:
2026-03-08 04:18:10 +07:00
parent 41d5e18f6b
commit bcc9cb2915
28 changed files with 2175 additions and 960 deletions

View File

@@ -1,4 +1,4 @@
import type { Options } from 'tsdown'; import type { InlineConfig } from 'tsdown';
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */'; const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
@@ -10,4 +10,4 @@ export const sharedConfig = {
outputOptions: { outputOptions: {
banner: BANNER, banner: BANNER,
}, },
} satisfies Options; } satisfies InlineConfig;

View File

@@ -1,5 +1,5 @@
import { bench, describe } from 'vitest'; import { bench, describe } from 'vitest';
import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '.'; import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '..';
/* -- Test data -- */ /* -- Test data -- */

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { encodeText, encodeBinary, makeSegments, isNumeric, isAlphanumeric, QrCode, EccMap, LOW, MEDIUM, HIGH } from '.'; import { encodeText, encodeBinary, encodeSegments, makeSegments, isNumeric, isAlphanumeric, QrCode, QrCodeDataType, EccMap, LOW, MEDIUM, QUARTILE, HIGH } from '..';
describe('isNumeric', () => { describe('isNumeric', () => {
it('accepts pure digit strings', () => { it('accepts pure digit strings', () => {
@@ -180,3 +180,78 @@ describe('EccMap', () => {
expect(qr).toBeInstanceOf(QrCode); expect(qr).toBeInstanceOf(QrCode);
}); });
}); });
describe('encodeSegments', () => {
it('uses explicit mask when specified', () => {
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, 3);
expect(qr.mask).toBe(3);
});
it('preserves ECC level when boostEcl is false', () => {
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, -1, false);
expect(qr.ecc).toBe(LOW);
});
it('boosts ECC level by default when data fits', () => {
const qr = encodeSegments(makeSegments('Test'), LOW);
expect(qr.ecc).toBe(HIGH);
});
it('forces a specific version when min equals max', () => {
const qr = encodeSegments(makeSegments('Test'), LOW, 5, 5);
expect(qr.version).toBe(5);
});
it('throws on invalid version range', () => {
expect(() => encodeSegments(makeSegments('Test'), LOW, 2, 1)).toThrow(RangeError);
});
it('throws on invalid mask value', () => {
expect(() => encodeSegments(makeSegments('Test'), LOW, 1, 40, 8)).toThrow(RangeError);
});
});
describe('encodeBinary edge cases', () => {
it('encodes an empty array', () => {
const qr = encodeBinary([], LOW);
expect(qr).toBeInstanceOf(QrCode);
});
});
describe('encodeText edge cases', () => {
it('encodes Unicode emoji text', () => {
const qr = encodeText('Hello \uD83C\uDF0D', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.size).toBeGreaterThanOrEqual(21);
});
it('uses compact encoding for alphanumeric text', () => {
const qr = encodeText('HELLO WORLD', LOW);
expect(qr.version).toBe(1);
});
it('selects version >= 7 for long data (triggers drawVersion)', () => {
const qr = encodeText('a'.repeat(200), LOW);
expect(qr.version).toBeGreaterThanOrEqual(7);
});
});
describe('getType semantics', () => {
it('identifies finder pattern modules as Position', () => {
const qr = encodeText('Test', LOW);
// Top-left finder pattern
expect(qr.getType(0, 0)).toBe(QrCodeDataType.Position);
expect(qr.getType(3, 3)).toBe(QrCodeDataType.Position);
expect(qr.getType(6, 6)).toBe(QrCodeDataType.Position);
// Top-right finder pattern
expect(qr.getType(qr.size - 1, 0)).toBe(QrCodeDataType.Position);
// Bottom-left finder pattern
expect(qr.getType(0, qr.size - 1)).toBe(QrCodeDataType.Position);
});
it('identifies timing pattern modules as Timing', () => {
const qr = encodeText('Test', LOW);
// Horizontal timing row y=6, between finders
expect(qr.getType(8, 6)).toBe(QrCodeDataType.Timing);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { QrSegment, makeNumeric, makeAlphanumeric, makeBytes } from '../segment';
import { MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC } from '../constants';
describe('QrSegment', () => {
it('throws on negative numChars', () => {
expect(() => new QrSegment(MODE_BYTE, -1, [])).toThrow(RangeError);
});
it('accepts zero numChars', () => {
const seg = new QrSegment(MODE_BYTE, 0, []);
expect(seg.numChars).toBe(0);
expect(seg.bitData).toEqual([]);
});
});
describe('makeNumeric', () => {
it('encodes a 5-digit string', () => {
const seg = makeNumeric('12345');
expect(seg.mode).toBe(MODE_NUMERIC);
expect(seg.numChars).toBe(5);
// "123" → 10 bits, "45" → 7 bits
expect(seg.bitData).toHaveLength(17);
});
it('encodes a single digit', () => {
const seg = makeNumeric('0');
expect(seg.numChars).toBe(1);
expect(seg.bitData).toHaveLength(4);
});
it('encodes an empty string', () => {
const seg = makeNumeric('');
expect(seg.numChars).toBe(0);
expect(seg.bitData).toHaveLength(0);
});
it('throws on non-numeric input', () => {
expect(() => makeNumeric('12a3')).toThrow(RangeError);
expect(() => makeNumeric('hello')).toThrow(RangeError);
});
});
describe('makeAlphanumeric', () => {
it('encodes a character pair', () => {
const seg = makeAlphanumeric('AB');
expect(seg.mode).toBe(MODE_ALPHANUMERIC);
expect(seg.numChars).toBe(2);
// 1 pair → 11 bits
expect(seg.bitData).toHaveLength(11);
});
it('encodes a pair plus remainder', () => {
const seg = makeAlphanumeric('ABC');
expect(seg.numChars).toBe(3);
// 1 pair (11 bits) + 1 remainder (6 bits)
expect(seg.bitData).toHaveLength(17);
});
it('throws on lowercase input', () => {
expect(() => makeAlphanumeric('hello')).toThrow(RangeError);
});
it('throws on invalid characters', () => {
expect(() => makeAlphanumeric('test@email')).toThrow(RangeError);
});
});
describe('makeBytes', () => {
it('encodes an empty array', () => {
const seg = makeBytes([]);
expect(seg.mode).toBe(MODE_BYTE);
expect(seg.numChars).toBe(0);
expect(seg.bitData).toHaveLength(0);
});
it('encodes two bytes', () => {
const seg = makeBytes([0x48, 0x65]);
expect(seg.numChars).toBe(2);
expect(seg.bitData).toHaveLength(16);
});
it('encodes 0xFF correctly', () => {
const seg = makeBytes([0xFF]);
expect(seg.bitData).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
});
it('encodes 0x00 correctly', () => {
const seg = makeBytes([0x00]);
expect(seg.bitData).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { appendBits, getBit, getNumDataCodewords, getNumRawDataModules, getTotalBits, numCharCountBits } from '../utils';
import { HIGH, LOW, MODE_BYTE, MODE_NUMERIC } from '../constants';
import { QrSegment } from '../segment';
describe('appendBits', () => {
it('appends nothing when len is 0', () => {
const bb: number[] = [];
appendBits(0, 0, bb);
expect(bb).toEqual([]);
});
it('appends bits in MSB-first order', () => {
const bb: number[] = [];
appendBits(0b101, 3, bb);
expect(bb).toEqual([1, 0, 1]);
});
it('appends to an existing array', () => {
const bb = [1, 0];
appendBits(0b11, 2, bb);
expect(bb).toEqual([1, 0, 1, 1]);
});
it('throws when value exceeds bit length', () => {
expect(() => appendBits(5, 2, [])).toThrow(RangeError);
});
it('throws on negative length', () => {
expect(() => appendBits(0, -1, [])).toThrow(RangeError);
});
});
describe('getBit', () => {
it('returns correct bits for 0b10110', () => {
expect(getBit(0b10110, 0)).toBe(false);
expect(getBit(0b10110, 1)).toBe(true);
expect(getBit(0b10110, 2)).toBe(true);
expect(getBit(0b10110, 3)).toBe(false);
expect(getBit(0b10110, 4)).toBe(true);
});
it('returns false for high bits of a small number', () => {
expect(getBit(1, 7)).toBe(false);
expect(getBit(1, 31)).toBe(false);
});
});
describe('getNumRawDataModules', () => {
it('returns 208 for version 1', () => {
expect(getNumRawDataModules(1)).toBe(208);
});
it('returns correct value for version 2 (with alignment)', () => {
expect(getNumRawDataModules(2)).toBe(359);
});
it('returns correct value for version 7 (with version info)', () => {
expect(getNumRawDataModules(7)).toBe(1568);
});
it('returns 29648 for version 40', () => {
expect(getNumRawDataModules(40)).toBe(29648);
});
it('throws on version 0', () => {
expect(() => getNumRawDataModules(0)).toThrow(RangeError);
});
it('throws on version 41', () => {
expect(() => getNumRawDataModules(41)).toThrow(RangeError);
});
});
describe('getNumDataCodewords', () => {
it('returns 19 for version 1 LOW', () => {
expect(getNumDataCodewords(1, LOW)).toBe(19);
});
it('returns 9 for version 1 HIGH', () => {
expect(getNumDataCodewords(1, HIGH)).toBe(9);
});
});
describe('getTotalBits', () => {
it('returns 0 for empty segments', () => {
expect(getTotalBits([], 1)).toBe(0);
});
it('returns Infinity when numChars overflows char count field', () => {
// MODE_BYTE at v1 has ccbits=8, so numChars=256 overflows
const seg = new QrSegment(MODE_BYTE, 256, []);
expect(getTotalBits([seg], 1)).toBe(Number.POSITIVE_INFINITY);
});
it('calculates total bits for a single segment', () => {
// MODE_BYTE at v1: 4 (mode) + 8 (char count) + 8 (data) = 20
const seg = new QrSegment(MODE_BYTE, 1, [0, 0, 0, 0, 0, 0, 0, 0]);
expect(getTotalBits([seg], 1)).toBe(20);
});
});
describe('numCharCountBits', () => {
it('returns correct bits for MODE_NUMERIC across version ranges', () => {
expect(numCharCountBits(MODE_NUMERIC, 1)).toBe(10);
expect(numCharCountBits(MODE_NUMERIC, 9)).toBe(10);
expect(numCharCountBits(MODE_NUMERIC, 10)).toBe(12);
expect(numCharCountBits(MODE_NUMERIC, 26)).toBe(12);
expect(numCharCountBits(MODE_NUMERIC, 27)).toBe(14);
expect(numCharCountBits(MODE_NUMERIC, 40)).toBe(14);
});
it('returns correct bits for MODE_BYTE across version ranges', () => {
expect(numCharCountBits(MODE_BYTE, 1)).toBe(8);
expect(numCharCountBits(MODE_BYTE, 9)).toBe(8);
expect(numCharCountBits(MODE_BYTE, 10)).toBe(16);
expect(numCharCountBits(MODE_BYTE, 40)).toBe(16);
});
});

View File

@@ -7,15 +7,10 @@
import type { QrCodeEcc } from './types'; import type { QrCodeEcc } from './types';
import { QrCodeDataType } from './types'; import { QrCodeDataType } from './types';
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS } from './constants'; import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS, PENALTY_N1, PENALTY_N2, PENALTY_N3, PENALTY_N4 } from './constants';
import { assert, getBit, getNumDataCodewords, getNumRawDataModules } from './utils'; import { assert, getBit, getNumDataCodewords, getNumRawDataModules } from './utils';
import { computeDivisor, computeRemainder } from '../reed-solomon'; import { computeDivisor, computeRemainder } from '../reed-solomon';
const PENALTY_N1 = 3;
const PENALTY_N2 = 3;
const PENALTY_N3 = 40;
const PENALTY_N4 = 10;
/** /**
* A QR Code symbol, which is a type of two-dimension barcode. * A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard. * Invented by Denso Wave and described in the ISO/IEC 18004 standard.
@@ -102,7 +97,7 @@ export class QrCode {
const size = this.size; const size = this.size;
// Draw horizontal and vertical timing patterns // Draw horizontal and vertical timing patterns
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
const dark = i % 2 === 0 ? 1 : 0; const dark = (i & 1) ^ 1;
this.setFunctionModule(6, i, dark, QrCodeDataType.Timing); this.setFunctionModule(6, i, dark, QrCodeDataType.Timing);
this.setFunctionModule(i, 6, dark, QrCodeDataType.Timing); this.setFunctionModule(i, 6, dark, QrCodeDataType.Timing);
} }
@@ -322,10 +317,8 @@ export class QrCode {
result++; result++;
} }
else { else {
// finderPenaltyAddHistory inlined
if (h0 === 0) runX += size; if (h0 === 0) runX += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX; h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX;
// finderPenaltyCountPatterns inlined (only when runColor is light = 0)
if (runColor === 0) { if (runColor === 0) {
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1; const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3; if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
@@ -335,7 +328,6 @@ export class QrCode {
runX = 1; runX = 1;
} }
} }
// finderPenaltyTerminateAndCount inlined
{ {
let currentRunLength = runX; let currentRunLength = runX;
if (runColor === 1) { if (runColor === 1) {
@@ -415,7 +407,7 @@ export class QrCode {
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
assert(k >= 0 && k <= 9); assert(k >= 0 && k <= 9);
result += k * PENALTY_N4; result += k * PENALTY_N4;
assert(result >= 0 && result <= 2568888); assert(result >= 0 && result <= 2_568_888);
return result; return result;
} }
@@ -423,14 +415,14 @@ export class QrCode {
if (this.version === 1) if (this.version === 1)
return []; return [];
const numAlign = ((this.version / 7) | 0) const numAlign = ((this.version / 7) | 0) + 2;
+ 2;
const step = (this.version === 32) const step = (this.version === 32)
? 26 ? 26
: Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
const result = [6]; const result = Array.from<number>({ length: numAlign });
for (let pos = this.size - 7; result.length < numAlign; pos -= step) result[0] = 6;
result.splice(1, 0, pos); for (let i = numAlign - 1, pos = this.size - 7; i >= 1; i--, pos -= step)
result[i] = pos;
return result; return result;
} }
} }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { computeDivisor, computeRemainder, multiply } from '.'; import { computeDivisor, computeRemainder, multiply } from '..';
describe('multiply', () => { describe('multiply', () => {
it('multiplies zero by anything to get zero', () => { it('multiplies zero by anything to get zero', () => {
@@ -97,4 +97,19 @@ describe('computeRemainder', () => {
expect(result).toHaveLength(degree); expect(result).toHaveLength(degree);
} }
}); });
it('produces correct ECC for QR Version 1-M reference data', () => {
const data = [0x40, 0xD2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xC6, 0xC6, 0x96, 0x70, 0xEC];
const divisor = computeDivisor(10);
const result = computeRemainder(data, divisor);
expect(result).toEqual(Uint8Array.from([188, 42, 144, 19, 107, 175, 239, 253, 75, 224]));
});
it('is deterministic', () => {
const data = [0x10, 0x20, 0x30, 0x40, 0x50];
const divisor = computeDivisor(7);
const a = computeRemainder(data, divisor);
const b = computeRemainder(data, divisor);
expect(a).toEqual(b);
});
}); });

1303
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,13 @@
import { defineConfig } from 'oxlint'; import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint'; import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic)); export default defineConfig(compose(base, typescript, imports, stylistic, {
overrides: [
{
files: ['**/*.vue'],
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
},
],
}));

View File

@@ -44,10 +44,14 @@
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:", "@stylistic/eslint-plugin": "catalog:",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"axe-core": "^4.11.1",
"oxlint": "catalog:", "oxlint": "catalog:",
"tsdown": "catalog:" "tsdown": "catalog:",
"unplugin-vue": "^7.1.1",
"vue-tsc": "^3.2.5"
}, },
"dependencies": { "dependencies": {
"@robonen/vue": "workspace:*",
"@vue/shared": "catalog:", "@vue/shared": "catalog:",
"vue": "catalog:" "vue": "catalog:"
} }

View File

@@ -1 +1,2 @@
export * from './primitive'; export * from './primitive';
export * from './pagination';

View 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>&#8230;</slot>
</Primitive>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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();
});
});

View 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();
});
});

View 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([]);
});
});

View 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;

View 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';

View 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;
}

View File

@@ -6,5 +6,9 @@
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
} }
},
"vueCompilerOptions": {
"strictTemplates": true,
"fallthroughAttributes": true
} }
} }

View File

@@ -1,9 +1,12 @@
import { defineConfig } from 'tsdown'; import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown'; import { sharedConfig } from '@robonen/tsdown';
import Vue from 'unplugin-vue/rolldown';
export default defineConfig({ export default defineConfig({
...sharedConfig, ...sharedConfig,
entry: ['src/index.ts'], entry: ['src/index.ts'],
plugins: [Vue({ isProduction: true })],
dts: { vue: true },
deps: { deps: {
neverBundle: ['vue'], neverBundle: ['vue'],
alwaysBundle: [/^@robonen\//, '@vue/shared'], alwaysBundle: [/^@robonen\//, '@vue/shared'],

View File

@@ -1,7 +1,9 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import Vue from 'unplugin-vue/vite';
export default defineConfig({ export default defineConfig({
plugins: [Vue()],
define: { define: {
__DEV__: 'true', __DEV__: 'true',
}, },