mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 02:44:45 +00:00
Compare commits
2 Commits
41d5e18f6b
...
a996eb74b9
| Author | SHA1 | Date | |
|---|---|---|---|
| a996eb74b9 | |||
| bcc9cb2915 |
@@ -26,9 +26,14 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": {
|
||||||
"import": "./dist/index.mjs",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -23,9 +23,14 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": {
|
||||||
"import": "./dist/index.js",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 -- */
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
core/encoding/src/qr/__test__/segment.test.ts
Normal file
92
core/encoding/src/qr/__test__/segment.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
core/encoding/src/qr/__test__/utils.test.ts
Normal file
119
core/encoding/src/qr/__test__/utils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -28,14 +28,24 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
"./browsers": {
|
"./browsers": {
|
||||||
"types": "./dist/browsers.d.ts",
|
"import": {
|
||||||
"import": "./dist/browsers.js",
|
"types": "./dist/browsers.d.mts",
|
||||||
"require": "./dist/browsers.cjs"
|
"default": "./dist/browsers.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/browsers.d.cts",
|
||||||
|
"default": "./dist/browsers.cjs"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"./multi": {
|
"./multi": {
|
||||||
"types": "./dist/multi.d.ts",
|
"import": {
|
||||||
"import": "./dist/multi.js",
|
"types": "./dist/multi.d.mts",
|
||||||
"require": "./dist/multi.cjs"
|
"default": "./dist/multi.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/multi.d.cts",
|
||||||
|
"default": "./dist/multi.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
139
core/platform/src/browsers/animationLifecycle/index.test.ts
Normal file
139
core/platform/src/browsers/animationLifecycle/index.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
getAnimationName,
|
||||||
|
isAnimatable,
|
||||||
|
shouldSuspendUnmount,
|
||||||
|
dispatchAnimationEvent,
|
||||||
|
onAnimationSettle,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
describe('getAnimationName', () => {
|
||||||
|
it('returns "none" for undefined element', () => {
|
||||||
|
expect(getAnimationName(undefined)).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the animation name from inline style', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.animationName = 'fadeIn';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
expect(getAnimationName(el)).toBe('fadeIn');
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAnimatable', () => {
|
||||||
|
it('returns false for undefined element', () => {
|
||||||
|
expect(isAnimatable(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for element with no animation or transition', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
expect(isAnimatable(el)).toBe(false);
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldSuspendUnmount', () => {
|
||||||
|
it('returns false for undefined element', () => {
|
||||||
|
expect(shouldSuspendUnmount(undefined, 'none')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for element with no animation/transition', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
expect(shouldSuspendUnmount(el, 'none')).toBe(false);
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispatchAnimationEvent', () => {
|
||||||
|
it('dispatches a custom event on the element', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
el.addEventListener('enter', handler);
|
||||||
|
dispatchAnimationEvent(el, 'enter');
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw for undefined element', () => {
|
||||||
|
expect(() => dispatchAnimationEvent(undefined, 'leave')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches non-bubbling event', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const parent = document.createElement('div');
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
parent.appendChild(el);
|
||||||
|
parent.addEventListener('enter', handler);
|
||||||
|
dispatchAnimationEvent(el, 'enter');
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAnimationSettle', () => {
|
||||||
|
it('returns a cleanup function', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const cleanup = onAnimationSettle(el, { onSettle: vi.fn() });
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSettle callback on transitionend', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
onAnimationSettle(el, { onSettle: callback });
|
||||||
|
el.dispatchEvent(new Event('transitionend'));
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSettle callback on transitioncancel', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
onAnimationSettle(el, { onSettle: callback });
|
||||||
|
el.dispatchEvent(new Event('transitioncancel'));
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onStart callback on animationstart', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const startCallback = vi.fn();
|
||||||
|
|
||||||
|
onAnimationSettle(el, {
|
||||||
|
onSettle: vi.fn(),
|
||||||
|
onStart: startCallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
el.dispatchEvent(new Event('animationstart'));
|
||||||
|
|
||||||
|
expect(startCallback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes all listeners on cleanup', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const cleanup = onAnimationSettle(el, { onSettle: callback });
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
el.dispatchEvent(new Event('transitionend'));
|
||||||
|
el.dispatchEvent(new Event('transitioncancel'));
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
core/platform/src/browsers/animationLifecycle/index.ts
Normal file
139
core/platform/src/browsers/animationLifecycle/index.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
export type AnimationLifecycleEvent = 'enter' | 'after-enter' | 'leave' | 'after-leave';
|
||||||
|
|
||||||
|
export interface AnimationSettleCallbacks {
|
||||||
|
onSettle: () => void;
|
||||||
|
onStart?: (animationName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getAnimationName
|
||||||
|
* @category Browsers
|
||||||
|
* @description Returns the current CSS animation name(s) of an element
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getAnimationName(el: HTMLElement | undefined): string {
|
||||||
|
return el ? getComputedStyle(el).animationName || 'none' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isAnimatable
|
||||||
|
* @category Browsers
|
||||||
|
* @description Checks whether an element has a running CSS animation or transition
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isAnimatable(el: HTMLElement | undefined): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const animationName = style.animationName || 'none';
|
||||||
|
const transitionProperty = style.transitionProperty || 'none';
|
||||||
|
|
||||||
|
const hasAnimation = animationName !== 'none' && animationName !== '';
|
||||||
|
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
|
||||||
|
|
||||||
|
return hasAnimation || hasTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name shouldSuspendUnmount
|
||||||
|
* @category Browsers
|
||||||
|
* @description Determines whether unmounting should be delayed due to a running animation/transition change
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function shouldSuspendUnmount(el: HTMLElement | undefined, prevAnimationName: string): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
|
||||||
|
if (style.display === 'none') return false;
|
||||||
|
|
||||||
|
const animationName = style.animationName || 'none';
|
||||||
|
const transitionProperty = style.transitionProperty || 'none';
|
||||||
|
|
||||||
|
const hasAnimation = animationName !== 'none' && animationName !== '';
|
||||||
|
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
|
||||||
|
|
||||||
|
if (!hasAnimation && !hasTransition) return false;
|
||||||
|
|
||||||
|
return prevAnimationName !== animationName || hasTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name dispatchAnimationEvent
|
||||||
|
* @category Browsers
|
||||||
|
* @description Dispatches a non-bubbling custom event on an element for animation lifecycle tracking
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function dispatchAnimationEvent(el: HTMLElement | undefined, name: AnimationLifecycleEvent): void {
|
||||||
|
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onAnimationSettle
|
||||||
|
* @category Browsers
|
||||||
|
* @description Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function.
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function onAnimationSettle(el: HTMLElement, callbacks: AnimationSettleCallbacks): () => void {
|
||||||
|
let fillModeTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const handleAnimationEnd = (event: AnimationEvent) => {
|
||||||
|
const currentAnimationName = getAnimationName(el);
|
||||||
|
const isCurrentAnimation = currentAnimationName.includes(CSS.escape(event.animationName));
|
||||||
|
|
||||||
|
if (event.target === el && isCurrentAnimation) {
|
||||||
|
callbacks.onSettle();
|
||||||
|
|
||||||
|
if (fillModeTimeoutId !== undefined) {
|
||||||
|
clearTimeout(fillModeTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFillMode = el.style.animationFillMode;
|
||||||
|
el.style.animationFillMode = 'forwards';
|
||||||
|
|
||||||
|
fillModeTimeoutId = setTimeout(() => {
|
||||||
|
if (el.style.animationFillMode === 'forwards') {
|
||||||
|
el.style.animationFillMode = currentFillMode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (event.target === el && currentAnimationName === 'none') {
|
||||||
|
callbacks.onSettle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimationStart = (event: AnimationEvent) => {
|
||||||
|
if (event.target === el) {
|
||||||
|
callbacks.onStart?.(getAnimationName(el));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransitionEnd = (event: TransitionEvent) => {
|
||||||
|
if (event.target === el) {
|
||||||
|
callbacks.onSettle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('animationstart', handleAnimationStart, { passive: true });
|
||||||
|
el.addEventListener('animationcancel', handleAnimationEnd, { passive: true });
|
||||||
|
el.addEventListener('animationend', handleAnimationEnd, { passive: true });
|
||||||
|
el.addEventListener('transitioncancel', handleTransitionEnd, { passive: true });
|
||||||
|
el.addEventListener('transitionend', handleTransitionEnd, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('animationstart', handleAnimationStart);
|
||||||
|
el.removeEventListener('animationcancel', handleAnimationEnd);
|
||||||
|
el.removeEventListener('animationend', handleAnimationEnd);
|
||||||
|
el.removeEventListener('transitioncancel', handleTransitionEnd);
|
||||||
|
el.removeEventListener('transitionend', handleTransitionEnd);
|
||||||
|
|
||||||
|
if (fillModeTimeoutId !== undefined) {
|
||||||
|
clearTimeout(fillModeTimeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './animationLifecycle';
|
||||||
export * from './focusGuard';
|
export * from './focusGuard';
|
||||||
|
|||||||
@@ -28,9 +28,14 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": {
|
||||||
"import": "./dist/index.js",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
1306
pnpm-lock.yaml
generated
1306
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|||||||
@@ -25,9 +25,14 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.mts",
|
"import": {
|
||||||
"import": "./dist/index.mjs",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -44,10 +49,15 @@
|
|||||||
"@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/platform": "workspace:*",
|
||||||
|
"@robonen/vue": "workspace:*",
|
||||||
"@vue/shared": "catalog:",
|
"@vue/shared": "catalog:",
|
||||||
"vue": "catalog:"
|
"vue": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import {
|
||||||
|
provideConfig,
|
||||||
|
provideAppConfig,
|
||||||
|
useConfig,
|
||||||
|
} from '..';
|
||||||
|
|
||||||
|
// --- useConfig ---
|
||||||
|
|
||||||
|
describe('useConfig', () => {
|
||||||
|
it('returns default config when no provider exists', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
const config = useConfig();
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', {
|
||||||
|
'data-dir': this.config.dir.value,
|
||||||
|
'data-target': this.config.teleportTarget.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
|
||||||
|
expect(wrapper.find('div').attributes('data-target')).toBe('body');
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns custom config from provideConfig', () => {
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const config = useConfig();
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', {
|
||||||
|
'data-dir': this.config.dir.value,
|
||||||
|
'data-target': this.config.teleportTarget.value,
|
||||||
|
'data-nonce': this.config.nonce.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Parent = defineComponent({
|
||||||
|
setup() {
|
||||||
|
provideConfig({
|
||||||
|
dir: 'rtl',
|
||||||
|
teleportTarget: '#app',
|
||||||
|
nonce: 'abc123',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h(Child);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(Parent);
|
||||||
|
|
||||||
|
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
|
||||||
|
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
|
||||||
|
expect(wrapper.find('div').attributes('data-nonce')).toBe('abc123');
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes mutable refs for runtime updates', async () => {
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const config = useConfig();
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', { 'data-dir': this.config.dir.value });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Parent = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const config = provideConfig({ dir: 'ltr' });
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h(Child);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(Parent);
|
||||||
|
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
|
||||||
|
|
||||||
|
wrapper.vm.config.dir.value = 'rtl';
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- provideAppConfig ---
|
||||||
|
|
||||||
|
describe('provideAppConfig', () => {
|
||||||
|
it('provides config at app level', () => {
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const config = useConfig();
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', {
|
||||||
|
'data-dir': this.config.dir.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(Child, {
|
||||||
|
global: {
|
||||||
|
plugins: [
|
||||||
|
app => provideAppConfig(app, { dir: 'rtl' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
vue/primitives/src/config-provider/context.ts
Normal file
45
vue/primitives/src/config-provider/context.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ref, shallowRef, toValue } from 'vue';
|
||||||
|
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||||
|
import { useContextFactory } from '@robonen/vue';
|
||||||
|
|
||||||
|
export type Direction = 'ltr' | 'rtl';
|
||||||
|
|
||||||
|
export interface ConfigContext {
|
||||||
|
dir: Ref<Direction>;
|
||||||
|
nonce: Ref<string | undefined>;
|
||||||
|
teleportTarget: ShallowRef<string | HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigOptions {
|
||||||
|
dir?: MaybeRefOrGetter<Direction>;
|
||||||
|
nonce?: MaybeRefOrGetter<string | undefined>;
|
||||||
|
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
|
||||||
|
dir: 'ltr',
|
||||||
|
nonce: undefined,
|
||||||
|
teleportTarget: 'body',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigCtx = useContextFactory<ConfigContext>('ConfigContext');
|
||||||
|
|
||||||
|
function resolveContext(options?: ConfigOptions): ConfigContext {
|
||||||
|
return {
|
||||||
|
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
|
||||||
|
nonce: ref(toValue(options?.nonce) ?? DEFAULT_CONFIG.nonce),
|
||||||
|
teleportTarget: shallowRef(toValue(options?.teleportTarget) ?? DEFAULT_CONFIG.teleportTarget),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provideConfig(options?: ConfigOptions): ConfigContext {
|
||||||
|
return ConfigCtx.provide(resolveContext(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provideAppConfig(app: App, options?: ConfigOptions): ConfigContext {
|
||||||
|
return ConfigCtx.appProvide(app)(resolveContext(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfig(): ConfigContext {
|
||||||
|
return ConfigCtx.inject(resolveContext());
|
||||||
|
}
|
||||||
8
vue/primitives/src/config-provider/index.ts
Normal file
8
vue/primitives/src/config-provider/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
provideConfig,
|
||||||
|
provideAppConfig,
|
||||||
|
useConfig,
|
||||||
|
type ConfigContext,
|
||||||
|
type ConfigOptions,
|
||||||
|
type Direction,
|
||||||
|
} from './context';
|
||||||
@@ -1 +1,4 @@
|
|||||||
|
export * from './config-provider';
|
||||||
export * from './primitive';
|
export * from './primitive';
|
||||||
|
export * from './presence';
|
||||||
|
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 { Ref } from 'vue';
|
||||||
|
import { useContextFactory } from '@robonen/vue';
|
||||||
|
|
||||||
|
export interface PaginationContext {
|
||||||
|
currentPage: Readonly<Ref<number>>;
|
||||||
|
totalPages: Readonly<Ref<number>>;
|
||||||
|
pageSize: Readonly<Ref<number>>;
|
||||||
|
siblingCount: Readonly<Ref<number>>;
|
||||||
|
showEdges: Readonly<Ref<boolean>>;
|
||||||
|
disabled: Readonly<Ref<boolean>>;
|
||||||
|
isFirstPage: Readonly<Ref<boolean>>;
|
||||||
|
isLastPage: Readonly<Ref<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;
|
||||||
|
}
|
||||||
28
vue/primitives/src/presence/Presence.vue
Normal file
28
vue/primitives/src/presence/Presence.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export interface PresenceProps {
|
||||||
|
present: boolean;
|
||||||
|
forceMount?: boolean;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePresence } from './usePresence';
|
||||||
|
|
||||||
|
const {
|
||||||
|
present,
|
||||||
|
forceMount = false,
|
||||||
|
} = defineProps<PresenceProps>();
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default?: (props: { present: boolean }) => any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isPresent, setRef } = usePresence(() => present);
|
||||||
|
|
||||||
|
defineExpose({ present: isPresent });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- @vue-expect-error ref is forwarded to slot -->
|
||||||
|
<slot v-if="forceMount || present || isPresent" :ref="setRef" :present="isPresent" />
|
||||||
|
</template>
|
||||||
356
vue/primitives/src/presence/__test__/Presence.test.ts
Normal file
356
vue/primitives/src/presence/__test__/Presence.test.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { usePresence } from '../usePresence';
|
||||||
|
import Presence from '../Presence.vue';
|
||||||
|
import {
|
||||||
|
getAnimationName,
|
||||||
|
shouldSuspendUnmount,
|
||||||
|
dispatchAnimationEvent,
|
||||||
|
onAnimationSettle,
|
||||||
|
} from '@robonen/platform/browsers';
|
||||||
|
|
||||||
|
vi.mock('@robonen/platform/browsers', () => ({
|
||||||
|
getAnimationName: vi.fn(() => 'none'),
|
||||||
|
shouldSuspendUnmount: vi.fn(() => false),
|
||||||
|
dispatchAnimationEvent: vi.fn((el, name) => {
|
||||||
|
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
|
||||||
|
}),
|
||||||
|
onAnimationSettle: vi.fn(() => vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetAnimationName = vi.mocked(getAnimationName);
|
||||||
|
const mockShouldSuspend = vi.mocked(shouldSuspendUnmount);
|
||||||
|
const mockDispatchEvent = vi.mocked(dispatchAnimationEvent);
|
||||||
|
const mockOnSettle = vi.mocked(onAnimationSettle);
|
||||||
|
|
||||||
|
function mountUsePresence(initial: boolean) {
|
||||||
|
const present = ref(initial);
|
||||||
|
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
const { isPresent } = usePresence(present);
|
||||||
|
return { isPresent };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', this.isPresent ? 'visible' : 'hidden');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { wrapper, present };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPresenceWithAnimation(present: Ref<boolean>) {
|
||||||
|
return mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
const { isPresent, setRef } = usePresence(present);
|
||||||
|
return { isPresent, setRef };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
if (!this.isPresent) return h('div', 'hidden');
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
ref: (el: any) => this.setRef(el),
|
||||||
|
}, 'visible');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDispatchCall(name: string) {
|
||||||
|
return mockDispatchEvent.mock.calls.find(([, n]) => n === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usePresence', () => {
|
||||||
|
it('returns isPresent=true when present is true', () => {
|
||||||
|
const { wrapper } = mountUsePresence(true);
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isPresent=false when present is false', () => {
|
||||||
|
const { wrapper } = mountUsePresence(false);
|
||||||
|
expect(wrapper.text()).toBe('hidden');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions to unmounted immediately when no animation', async () => {
|
||||||
|
const { wrapper, present } = mountUsePresence(true);
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
|
||||||
|
present.value = false;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toBe('hidden');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions to mounted when present becomes true', async () => {
|
||||||
|
const { wrapper, present } = mountUsePresence(false);
|
||||||
|
expect(wrapper.text()).toBe('hidden');
|
||||||
|
|
||||||
|
present.value = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Presence', () => {
|
||||||
|
it('renders child when present is true', () => {
|
||||||
|
const wrapper = mount(Presence, {
|
||||||
|
props: { present: true },
|
||||||
|
slots: { default: () => h('div', 'content') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.html()).toContain('content');
|
||||||
|
expect(wrapper.find('div').exists()).toBe(true);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render child when present is false', () => {
|
||||||
|
const wrapper = mount(Presence, {
|
||||||
|
props: { present: false },
|
||||||
|
slots: { default: () => h('div', 'content') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.html()).not.toContain('content');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes child when present becomes false (no animation)', async () => {
|
||||||
|
const present = ref(true);
|
||||||
|
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h(Presence, { present: present.value }, {
|
||||||
|
default: () => h('span', 'hello'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(wrapper.find('span').exists()).toBe(true);
|
||||||
|
|
||||||
|
present.value = false;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('span').exists()).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds child when present becomes true', async () => {
|
||||||
|
const present = ref(false);
|
||||||
|
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h(Presence, { present: present.value }, {
|
||||||
|
default: () => h('span', 'hello'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(wrapper.find('span').exists()).toBe(false);
|
||||||
|
|
||||||
|
present.value = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('span').exists()).toBe(true);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always renders child when forceMount is true', () => {
|
||||||
|
const wrapper = mount(Presence, {
|
||||||
|
props: { present: false, forceMount: true },
|
||||||
|
slots: { default: () => h('span', 'always') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('span').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('span').text()).toBe('always');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes present state via scoped slot', () => {
|
||||||
|
let slotPresent: boolean | undefined;
|
||||||
|
|
||||||
|
const wrapper = mount(Presence, {
|
||||||
|
props: { present: true },
|
||||||
|
slots: {
|
||||||
|
default: (props: { present: boolean }) => {
|
||||||
|
slotPresent = props.present;
|
||||||
|
return h('div', 'content');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slotPresent).toBe(true);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes present=false via scoped slot when forceMount and not present', () => {
|
||||||
|
let slotPresent: boolean | undefined;
|
||||||
|
|
||||||
|
const wrapper = mount(Presence, {
|
||||||
|
props: { present: false, forceMount: true },
|
||||||
|
slots: {
|
||||||
|
default: (props: { present: boolean }) => {
|
||||||
|
slotPresent = props.present;
|
||||||
|
return h('div', 'content');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slotPresent).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usePresence (animation)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetAnimationName.mockReturnValue('none');
|
||||||
|
mockShouldSuspend.mockReturnValue(false);
|
||||||
|
mockOnSettle.mockImplementation(() => vi.fn());
|
||||||
|
mockDispatchEvent.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches enter and after-enter when present becomes true (no animation)', async () => {
|
||||||
|
const present = ref(false);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
mockDispatchEvent.mockClear();
|
||||||
|
|
||||||
|
present.value = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findDispatchCall('enter')).toBeTruthy();
|
||||||
|
expect(findDispatchCall('after-enter')).toBeTruthy();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches leave and after-leave when no animation on leave', async () => {
|
||||||
|
const present = ref(true);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
await nextTick();
|
||||||
|
mockDispatchEvent.mockClear();
|
||||||
|
|
||||||
|
present.value = false;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findDispatchCall('leave')).toBeTruthy();
|
||||||
|
expect(findDispatchCall('after-leave')).toBeTruthy();
|
||||||
|
expect(wrapper.text()).toBe('hidden');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suspends unmount when shouldSuspendUnmount returns true', async () => {
|
||||||
|
mockShouldSuspend.mockReturnValue(true);
|
||||||
|
|
||||||
|
const present = ref(true);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
await nextTick();
|
||||||
|
mockDispatchEvent.mockClear();
|
||||||
|
|
||||||
|
present.value = false;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findDispatchCall('leave')).toBeTruthy();
|
||||||
|
expect(findDispatchCall('after-leave')).toBeUndefined();
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches after-leave and unmounts when animation settles', async () => {
|
||||||
|
mockShouldSuspend.mockReturnValue(true);
|
||||||
|
|
||||||
|
let settleCallback: (() => void) | undefined;
|
||||||
|
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
|
||||||
|
settleCallback = callbacks.onSettle;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const present = ref(true);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
await nextTick();
|
||||||
|
mockDispatchEvent.mockClear();
|
||||||
|
|
||||||
|
present.value = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
|
||||||
|
settleCallback!();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(findDispatchCall('after-leave')).toBeTruthy();
|
||||||
|
expect(wrapper.text()).toBe('hidden');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks animation name on start via onAnimationSettle', async () => {
|
||||||
|
let startCallback: ((name: string) => void) | undefined;
|
||||||
|
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
|
||||||
|
startCallback = callbacks.onStart;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const present = ref(true);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(startCallback).toBeDefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls cleanup returned by onAnimationSettle on unmount', async () => {
|
||||||
|
const cleanupFn = vi.fn();
|
||||||
|
mockOnSettle.mockReturnValue(cleanupFn);
|
||||||
|
|
||||||
|
const present = ref(true);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
await nextTick();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setRef connects DOM element for animation tracking', async () => {
|
||||||
|
const present = ref(true);
|
||||||
|
const wrapper = mountPresenceWithAnimation(present);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
expect(mockOnSettle).toHaveBeenCalled();
|
||||||
|
expect(mockOnSettle.mock.calls[0]![0]).toBeInstanceOf(HTMLElement);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets isAnimating when node ref becomes undefined', async () => {
|
||||||
|
mockShouldSuspend.mockReturnValue(true);
|
||||||
|
|
||||||
|
mockOnSettle.mockImplementation(() => vi.fn());
|
||||||
|
|
||||||
|
const present = ref(true);
|
||||||
|
const showEl = ref(true);
|
||||||
|
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
const { isPresent, setRef } = usePresence(present);
|
||||||
|
return { isPresent, setRef, showEl };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
if (!showEl.value) {
|
||||||
|
this.setRef(undefined);
|
||||||
|
return h('div', 'no-el');
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
ref: (el: any) => this.setRef(el),
|
||||||
|
}, this.isPresent ? 'visible' : 'hidden');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.text()).toBe('visible');
|
||||||
|
|
||||||
|
showEl.value = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.text()).toBe('no-el');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
5
vue/primitives/src/presence/index.ts
Normal file
5
vue/primitives/src/presence/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as Presence } from './Presence.vue';
|
||||||
|
export { usePresence } from './usePresence';
|
||||||
|
|
||||||
|
export type { PresenceProps } from './Presence.vue';
|
||||||
|
export type { UsePresenceReturn } from './usePresence';
|
||||||
84
vue/primitives/src/presence/usePresence.ts
Normal file
84
vue/primitives/src/presence/usePresence.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { MaybeElement } from '@robonen/vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
|
||||||
|
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
||||||
|
import {
|
||||||
|
dispatchAnimationEvent,
|
||||||
|
getAnimationName,
|
||||||
|
onAnimationSettle,
|
||||||
|
shouldSuspendUnmount,
|
||||||
|
} from '@robonen/platform/browsers';
|
||||||
|
|
||||||
|
export interface UsePresenceReturn {
|
||||||
|
isPresent: Readonly<Ref<boolean>>;
|
||||||
|
setRef: (v: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePresence(
|
||||||
|
present: MaybeRefOrGetter<boolean>,
|
||||||
|
): UsePresenceReturn {
|
||||||
|
const node = shallowRef<HTMLElement>();
|
||||||
|
const isAnimating = shallowRef(false);
|
||||||
|
let prevAnimationName = 'none';
|
||||||
|
|
||||||
|
const isPresent = computed(() => toValue(present) || isAnimating.value);
|
||||||
|
|
||||||
|
watch(isPresent, (current) => {
|
||||||
|
prevAnimationName = current ? getAnimationName(node.value) : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => toValue(present), (value, oldValue) => {
|
||||||
|
if (value === oldValue) return;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
isAnimating.value = false;
|
||||||
|
dispatchAnimationEvent(node.value, 'enter');
|
||||||
|
|
||||||
|
if (getAnimationName(node.value) === 'none') {
|
||||||
|
dispatchAnimationEvent(node.value, 'after-enter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isAnimating.value = shouldSuspendUnmount(node.value, prevAnimationName);
|
||||||
|
dispatchAnimationEvent(node.value, 'leave');
|
||||||
|
|
||||||
|
if (!isAnimating.value) {
|
||||||
|
dispatchAnimationEvent(node.value, 'after-leave');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { flush: 'sync' });
|
||||||
|
|
||||||
|
watch(node, (el, _oldEl, onCleanup) => {
|
||||||
|
if (el) {
|
||||||
|
const cleanup = onAnimationSettle(el, {
|
||||||
|
onSettle: () => {
|
||||||
|
const direction = toValue(present) ? 'enter' : 'leave';
|
||||||
|
dispatchAnimationEvent(el, `after-${direction}`);
|
||||||
|
isAnimating.value = false;
|
||||||
|
},
|
||||||
|
onStart: (animationName) => {
|
||||||
|
prevAnimationName = animationName;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(cleanup);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isAnimating.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tryOnScopeDispose(() => {
|
||||||
|
isAnimating.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setRef(v: unknown) {
|
||||||
|
const el = unrefElement(v as MaybeElement);
|
||||||
|
node.value = el instanceof HTMLElement ? el : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPresent: readonly(isPresent),
|
||||||
|
setRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Component, IntrinsicElementAttributes, SetupContext } from 'vue';
|
import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
|
||||||
import { h } from 'vue';
|
import { h, mergeProps } from 'vue';
|
||||||
import { Slot } from './Slot';
|
import { Slot } from './Slot';
|
||||||
|
|
||||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||||
@@ -8,10 +8,12 @@ export interface PrimitiveProps {
|
|||||||
as?: keyof IntrinsicElementAttributes | Component;
|
as?: keyof IntrinsicElementAttributes | Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Primitive(props: PrimitiveProps, ctx: FunctionalComponentContext) {
|
export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record<string, unknown>, ctx: FunctionalComponentContext) {
|
||||||
return props.as === 'template'
|
const { as, ...delegatedProps } = props;
|
||||||
? h(Slot, ctx.attrs, ctx.slots)
|
|
||||||
: h(props.as!, ctx.attrs, ctx.slots);
|
return as === 'template'
|
||||||
|
? h(Slot, mergeProps(ctx.attrs, delegatedProps), ctx.slots)
|
||||||
|
: h(as!, mergeProps(ctx.attrs, delegatedProps), ctx.slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
Primitive.props = {
|
Primitive.props = {
|
||||||
|
|||||||
@@ -6,5 +6,12 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"vueCompilerOptions": {
|
||||||
|
"strictTemplates": true,
|
||||||
|
"fallthroughAttributes": true,
|
||||||
|
"inferTemplateDollarAttrs": true,
|
||||||
|
"inferTemplateDollarEl": true,
|
||||||
|
"inferTemplateDollarRefs": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,9 +26,14 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": {
|
||||||
"import": "./dist/index.js",
|
"types": "./dist/index.d.mts",
|
||||||
"require": "./dist/index.cjs"
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
|
|||||||
import { toValue } from 'vue';
|
import { toValue } from 'vue';
|
||||||
|
|
||||||
export type VueInstance = ComponentPublicInstance;
|
export type VueInstance = ComponentPublicInstance;
|
||||||
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
|
export type MaybeElement = Element | VueInstance | undefined | null;
|
||||||
|
|
||||||
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
|
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
|
||||||
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
|
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
|
||||||
|
|||||||
Reference in New Issue
Block a user