mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Compare commits
7 Commits
41d5e18f6b
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1111848728 | ||
|
|
170093a039 | ||
|
|
11f823afb4 | ||
| 5fa38110b7 | |||
| 4574bae0b6 | |||
| a996eb74b9 | |||
| bcc9cb2915 |
@@ -26,9 +26,14 @@
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -17,6 +17,6 @@ export const imports: OxlintConfig = {
|
||||
'import/no-empty-named-blocks': 'warn',
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
|
||||
|
||||
'sort-imports': ['warn', { ignoreDeclarationSort: false, ignoreMemberSort: false, ignoreCase: true, allowSeparatedGroups: true }],
|
||||
'sort-imports': 'warn',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export const stylistic: OxlintConfig = {
|
||||
'@stylistic/comma-style': ['error', 'last'],
|
||||
'@stylistic/semi': ['error', 'always'],
|
||||
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
|
||||
'@stylistic/quote-props': ['error', 'consistent-as-needed'],
|
||||
'@stylistic/quote-props': ['error', 'as-needed'],
|
||||
|
||||
/* ── indentation ──────────────────────────────────────── */
|
||||
'@stylistic/indent': ['error', 2, {
|
||||
|
||||
@@ -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 */';
|
||||
|
||||
@@ -10,4 +10,4 @@ export const sharedConfig = {
|
||||
outputOptions: {
|
||||
banner: BANNER,
|
||||
},
|
||||
} satisfies Options;
|
||||
} satisfies InlineConfig;
|
||||
|
||||
@@ -23,9 +23,14 @@
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '.';
|
||||
import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '..';
|
||||
|
||||
/* -- Test data -- */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
it('accepts pure digit strings', () => {
|
||||
@@ -180,3 +180,78 @@ describe('EccMap', () => {
|
||||
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 { 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 { 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.
|
||||
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
|
||||
@@ -102,7 +97,7 @@ export class QrCode {
|
||||
const size = this.size;
|
||||
// Draw horizontal and vertical timing patterns
|
||||
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(i, 6, dark, QrCodeDataType.Timing);
|
||||
}
|
||||
@@ -322,10 +317,8 @@ export class QrCode {
|
||||
result++;
|
||||
}
|
||||
else {
|
||||
// finderPenaltyAddHistory inlined
|
||||
if (h0 === 0) runX += size;
|
||||
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) {
|
||||
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
|
||||
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
|
||||
@@ -335,7 +328,6 @@ export class QrCode {
|
||||
runX = 1;
|
||||
}
|
||||
}
|
||||
// finderPenaltyTerminateAndCount inlined
|
||||
{
|
||||
let currentRunLength = runX;
|
||||
if (runColor === 1) {
|
||||
@@ -415,7 +407,7 @@ export class QrCode {
|
||||
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
|
||||
assert(k >= 0 && k <= 9);
|
||||
result += k * PENALTY_N4;
|
||||
assert(result >= 0 && result <= 2568888);
|
||||
assert(result >= 0 && result <= 2_568_888);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -423,14 +415,14 @@ export class QrCode {
|
||||
if (this.version === 1)
|
||||
return [];
|
||||
|
||||
const numAlign = ((this.version / 7) | 0)
|
||||
+ 2;
|
||||
const numAlign = ((this.version / 7) | 0) + 2;
|
||||
const step = (this.version === 32)
|
||||
? 26
|
||||
: Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
|
||||
const result = [6];
|
||||
for (let pos = this.size - 7; result.length < numAlign; pos -= step)
|
||||
result.splice(1, 0, pos);
|
||||
const result = Array.from<number>({ length: numAlign });
|
||||
result[0] = 6;
|
||||
for (let i = numAlign - 1, pos = this.size - 7; i >= 1; i--, pos -= step)
|
||||
result[i] = pos;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeDivisor, computeRemainder, multiply } from '.';
|
||||
import { computeDivisor, computeRemainder, multiply } from '..';
|
||||
|
||||
describe('multiply', () => {
|
||||
it('multiplies zero by anything to get zero', () => {
|
||||
@@ -97,4 +97,19 @@ describe('computeRemainder', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
6
core/fetch/jsr.json
Normal file
6
core/fetch/jsr.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/fetch",
|
||||
"version": "0.0.1",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
4
core/fetch/oxlint.config.ts
Normal file
4
core/fetch/oxlint.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||
53
core/fetch/package.json
Normal file
53
core/fetch/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@robonen/fetch",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
|
||||
"keywords": [
|
||||
"fetch",
|
||||
"http",
|
||||
"request",
|
||||
"tools"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "core/fetch"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:check": "oxlint -c oxlint.config.ts",
|
||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
}
|
||||
}
|
||||
71
core/fetch/src/error.test.ts
Normal file
71
core/fetch/src/error.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FetchError, createFetchError } from './error';
|
||||
import type { FetchContext } from './types';
|
||||
|
||||
function makeContext(overrides: Partial<FetchContext> = {}): FetchContext {
|
||||
return {
|
||||
request: 'https://example.com/api',
|
||||
options: { headers: new Headers() },
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
...overrides,
|
||||
} as FetchContext;
|
||||
}
|
||||
|
||||
describe('FetchError', () => {
|
||||
it('is an instance of Error', () => {
|
||||
const err = new FetchError('oops');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(FetchError);
|
||||
});
|
||||
|
||||
it('has name "FetchError"', () => {
|
||||
expect(new FetchError('x').name).toBe('FetchError');
|
||||
});
|
||||
|
||||
it('preserves the message', () => {
|
||||
expect(new FetchError('something went wrong').message).toBe('something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFetchError', () => {
|
||||
it('includes the request URL in the message', () => {
|
||||
const err = createFetchError(makeContext());
|
||||
expect(err.message).toContain('https://example.com/api');
|
||||
});
|
||||
|
||||
it('appends status information when a response is present', () => {
|
||||
const response = new Response('', { status: 404, statusText: 'Not Found' });
|
||||
const err = createFetchError(makeContext({ response: response as never }));
|
||||
expect(err.message).toContain('404');
|
||||
expect(err.message).toContain('Not Found');
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.statusText).toBe('Not Found');
|
||||
expect(err.statusMessage).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('appends the underlying error message when present', () => {
|
||||
const networkErr = new Error('Failed to fetch');
|
||||
const err = createFetchError(makeContext({ error: networkErr }));
|
||||
expect(err.message).toContain('Failed to fetch');
|
||||
});
|
||||
|
||||
it('populates response._data as data', () => {
|
||||
const response = Object.assign(new Response('', { status: 422 }), { _data: { code: 42 } });
|
||||
const err = createFetchError(makeContext({ response: response as never }));
|
||||
expect(err.data).toEqual({ code: 42 });
|
||||
});
|
||||
|
||||
it('works with a URL object as request', () => {
|
||||
const ctx = makeContext({ request: new URL('https://example.com/test') });
|
||||
const err = createFetchError(ctx);
|
||||
expect(err.message).toContain('https://example.com/test');
|
||||
});
|
||||
|
||||
it('works with a Request object as request', () => {
|
||||
const ctx = makeContext({ request: new Request('https://example.com/req') });
|
||||
const err = createFetchError(ctx);
|
||||
expect(err.message).toContain('https://example.com/req');
|
||||
});
|
||||
});
|
||||
70
core/fetch/src/error.ts
Normal file
70
core/fetch/src/error.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { FetchContext, FetchOptions, FetchRequest, FetchResponse, IFetchError } from './types';
|
||||
|
||||
/**
|
||||
* @name FetchError
|
||||
* @category Fetch
|
||||
* @description Error thrown by $fetch on network failures or non-2xx responses
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export class FetchError<T = unknown> extends Error implements IFetchError<T> {
|
||||
request?: FetchRequest;
|
||||
options?: FetchOptions;
|
||||
response?: FetchResponse<T>;
|
||||
data?: T;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
statusCode?: number;
|
||||
statusMessage?: string;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FetchError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createFetchError
|
||||
* @category Fetch
|
||||
* @description Builds a FetchError from a FetchContext, extracting URL, status, and error message
|
||||
*
|
||||
* @param {FetchContext} context - The context at the point of failure
|
||||
* @returns {FetchError} A populated FetchError instance
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function createFetchError<T = unknown>(context: FetchContext<T>): FetchError<T> {
|
||||
const url
|
||||
= typeof context.request === 'string'
|
||||
? context.request
|
||||
: context.request instanceof URL
|
||||
? context.request.href
|
||||
: (context.request as Request).url;
|
||||
|
||||
const statusPart = context.response
|
||||
? `${context.response.status} ${context.response.statusText}`
|
||||
: '';
|
||||
|
||||
const errorPart = context.error?.message ?? '';
|
||||
|
||||
// Build message from non-empty parts
|
||||
let message = url;
|
||||
if (statusPart) message += ` ${statusPart}`;
|
||||
if (errorPart) message += `: ${errorPart}`;
|
||||
|
||||
const error = new FetchError<T>(message);
|
||||
|
||||
error.request = context.request;
|
||||
error.options = context.options;
|
||||
|
||||
if (context.response !== undefined) {
|
||||
error.response = context.response;
|
||||
error.data = context.response._data;
|
||||
error.status = context.response.status;
|
||||
error.statusText = context.response.statusText;
|
||||
error.statusCode = context.response.status;
|
||||
error.statusMessage = context.response.statusText;
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
526
core/fetch/src/fetch.test.ts
Normal file
526
core/fetch/src/fetch.test.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FetchError } from './error';
|
||||
import { createFetch } from './fetch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFetchMock(
|
||||
body: unknown = { ok: true },
|
||||
init: ResponseInit = { status: 200 },
|
||||
contentType = 'application/json',
|
||||
): ReturnType<typeof vi.fn> {
|
||||
return vi.fn().mockResolvedValue(
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
...init,
|
||||
headers: { 'content-type': contentType, ...init.headers },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createFetch — basic', () => {
|
||||
it('returns parsed JSON body', async () => {
|
||||
const fetchMock = makeFetchMock({ id: 1 });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch<{ id: number }>('https://api.example.com/user');
|
||||
|
||||
expect(data).toEqual({ id: 1 });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('passes options through to the underlying fetch', async () => {
|
||||
const fetchMock = makeFetchMock({ done: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/task', {
|
||||
method: 'POST',
|
||||
headers: { 'x-token': 'abc' },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Headers).get('x-token')).toBe('abc');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('uppercases the HTTP method', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com', { method: 'post' });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// raw
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('$fetch.raw', () => {
|
||||
it('returns a Response with _data', async () => {
|
||||
const fetchMock = makeFetchMock({ value: 42 });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const response = await $fetch.raw<{ value: number }>('https://api.example.com');
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response._data).toEqual({ value: 42 });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Method shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('method shortcuts', () => {
|
||||
it('$fetch.get sends a GET request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.get('https://api.example.com/items');
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('$fetch.post sends a POST request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.post('https://api.example.com/items', { body: { name: 'x' } });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('$fetch.put sends a PUT request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.put('https://api.example.com/items/1', { body: { name: 'y' } });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('$fetch.patch sends a PATCH request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.patch('https://api.example.com/items/1', { body: { name: 'z' } });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PATCH');
|
||||
});
|
||||
|
||||
it('$fetch.delete sends a DELETE request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.delete('https://api.example.com/items/1');
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// baseURL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('baseURL', () => {
|
||||
it('prepends baseURL to a relative path', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('/users', { baseURL: 'https://api.example.com/v1' });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/v1/users');
|
||||
});
|
||||
|
||||
it('inherits baseURL from create() defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const api = createFetch({ fetch: fetchMock }).create({ baseURL: 'https://api.example.com' });
|
||||
|
||||
await api('/health');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/health');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query params
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('query params', () => {
|
||||
it('appends query to the request URL', async () => {
|
||||
const fetchMock = makeFetchMock([]);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/items', { query: { page: 2, limit: 10 } });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toContain('page=2');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('merges default query with per-request query', async () => {
|
||||
const fetchMock = makeFetchMock([]);
|
||||
const $fetch = createFetch({ fetch: fetchMock }).create({
|
||||
baseURL: 'https://api.example.com',
|
||||
query: { version: 2 },
|
||||
});
|
||||
|
||||
await $fetch('/items', { query: { page: 1 } });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toContain('version=2');
|
||||
expect(url).toContain('page=1');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON body serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('JSON body serialisation', () => {
|
||||
it('serialises plain objects and sets content-type to application/json', async () => {
|
||||
const fetchMock = makeFetchMock({ ok: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/users', {
|
||||
method: 'POST',
|
||||
body: { name: 'Alice' },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe('{"name":"Alice"}');
|
||||
expect((init.headers as Headers).get('content-type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('respects a pre-set content-type header', async () => {
|
||||
const fetchMock = makeFetchMock({ ok: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/form', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: { key: 'value' },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe('key=value');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws FetchError on 4xx response', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{"error":"not found"}', {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect($fetch('https://api.example.com/missing')).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
|
||||
it('throws FetchError on 5xx response', async () => {
|
||||
// Use mockImplementation so each retry attempt gets a fresh Response (body not yet read)
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async () => new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect($fetch('https://api.example.com/crash')).rejects.toThrow(FetchError);
|
||||
});
|
||||
|
||||
it('does not throw when ignoreResponseError is true', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{"error":"bad request"}', {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/bad', { ignoreResponseError: true }),
|
||||
).resolves.toEqual({ error: 'bad request' });
|
||||
});
|
||||
|
||||
it('throws FetchError on network error', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect($fetch('https://api.example.com/offline')).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('retry', () => {
|
||||
it('retries once on 500 by default for GET', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('error', { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch('https://api.example.com/flaky');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(data).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not retry POST by default', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/task', { method: 'POST' }),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects retry: false', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 503 }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/flaky', { retry: false }),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects custom retryStatusCodes', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('', { status: 418 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch('https://api.example.com/teapot', {
|
||||
retryStatusCodes: [418],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(data).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lifecycle hooks', () => {
|
||||
it('calls onRequest before sending', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onRequest: () => {
|
||||
calls.push('request');
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toContain('request');
|
||||
expect(calls.indexOf('request')).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('calls onResponse after a successful response', async () => {
|
||||
const fetchMock = makeFetchMock({ data: 1 });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onResponse: () => {
|
||||
calls.push('response');
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toContain('response');
|
||||
});
|
||||
|
||||
it('calls onResponseError for 4xx responses', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/protected', {
|
||||
retry: false,
|
||||
onResponseError: () => {
|
||||
calls.push('responseError');
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(calls).toContain('responseError');
|
||||
});
|
||||
|
||||
it('calls onRequestError on network failure', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Network error'));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/offline', {
|
||||
retry: false,
|
||||
onRequestError: () => {
|
||||
calls.push('requestError');
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(calls).toContain('requestError');
|
||||
});
|
||||
|
||||
it('supports multiple hooks as an array', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: number[] = [];
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onRequest: [
|
||||
() => {
|
||||
calls.push(1);
|
||||
},
|
||||
() => {
|
||||
calls.push(2);
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create / extend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('create and extend', () => {
|
||||
it('creates a new instance with merged defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const api = $fetch.create({ baseURL: 'https://api.example.com' });
|
||||
|
||||
await api('/ping');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/ping');
|
||||
});
|
||||
|
||||
it('extend is an alias for create', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const api = $fetch.extend({ baseURL: 'https://api.example.com' });
|
||||
|
||||
await api('/ping');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/ping');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response type variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('response types', () => {
|
||||
it('returns text when responseType is "text"', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('hello world', { headers: { 'content-type': 'text/plain' } }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const text = await $fetch<string, 'text'>('https://api.example.com/text', {
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
expect(text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns a Blob when responseType is "blob"', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('binary', { headers: { 'content-type': 'image/png' } }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const blob = await $fetch<Blob, 'blob'>('https://api.example.com/img', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('uses a custom parseResponse function', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch<{ value: number }>('https://api.example.com/custom', {
|
||||
parseResponse: text => ({ ...JSON.parse(text) as object, custom: true }),
|
||||
});
|
||||
|
||||
expect(data).toEqual({ value: 10, custom: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('aborts a request that exceeds the timeout', async () => {
|
||||
// fetchMock that never resolves until the signal fires
|
||||
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
(init.signal as AbortSignal).addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const promise = $fetch('https://api.example.com/slow', { timeout: 100, retry: false });
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
});
|
||||
324
core/fetch/src/fetch.ts
Normal file
324
core/fetch/src/fetch.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { ResponseMap, $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, ResponseType } from './types';
|
||||
import { createFetchError } from './error';
|
||||
import {
|
||||
NULL_BODY_STATUSES,
|
||||
buildURL,
|
||||
callHooks,
|
||||
detectResponseType,
|
||||
isJSONSerializable,
|
||||
isPayloadMethod,
|
||||
joinURL,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V8: module-level Set — initialised once, never mutated, allows V8 to
|
||||
// embed the set reference as a constant in compiled code.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** HTTP status codes that trigger automatic retry by default */
|
||||
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
||||
408, // Request Timeout
|
||||
409, // Conflict
|
||||
425, // Too Early (Experimental)
|
||||
429, // Too Many Requests
|
||||
500, // Internal Server Error
|
||||
502, // Bad Gateway
|
||||
503, // Service Unavailable
|
||||
504, // Gateway Timeout
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createFetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name createFetch
|
||||
* @category Fetch
|
||||
* @description Creates a configured $fetch instance
|
||||
*
|
||||
* V8 optimisation notes:
|
||||
* - All inner objects are created with a fixed property set so V8 can reuse
|
||||
* their hidden class across invocations (no dynamic property additions).
|
||||
* - `Error.captureStackTrace` is called only when available (V8 / Node.js)
|
||||
* to produce clean stack traces without internal frames.
|
||||
* - Retry and timeout paths avoid allocating closures on the hot path.
|
||||
* - `NULL_BODY_STATUSES` / `DEFAULT_RETRY_STATUS_CODES` are frozen module-
|
||||
* level Sets, so their `.has()` calls are always monomorphic.
|
||||
*
|
||||
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation
|
||||
* @returns {$Fetch} Configured fetch instance
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handler — shared between network errors and 4xx/5xx responses
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function onError(context: FetchContext): Promise<FetchResponse<unknown>> {
|
||||
// Explicit user-triggered abort should not be retried automatically
|
||||
const isAbort
|
||||
= context.error !== undefined
|
||||
&& context.error.name === 'AbortError'
|
||||
&& context.options.timeout === undefined;
|
||||
|
||||
if (!isAbort && context.options.retry !== false) {
|
||||
// Default retry count: 0 for payload methods, 1 for idempotent methods
|
||||
const maxRetries
|
||||
= typeof context.options.retry === 'number'
|
||||
? context.options.retry
|
||||
: isPayloadMethod(context.options.method ?? 'GET')
|
||||
? 0
|
||||
: 1;
|
||||
|
||||
if (maxRetries > 0) {
|
||||
const responseStatus = context.response?.status ?? 500;
|
||||
const retryStatusCodes = context.options.retryStatusCodes;
|
||||
const shouldRetry
|
||||
= retryStatusCodes !== undefined
|
||||
? retryStatusCodes.includes(responseStatus)
|
||||
: DEFAULT_RETRY_STATUS_CODES.has(responseStatus);
|
||||
|
||||
if (shouldRetry) {
|
||||
const retryDelay
|
||||
= typeof context.options.retryDelay === 'function'
|
||||
? context.options.retryDelay(context)
|
||||
: (context.options.retryDelay ?? 0);
|
||||
|
||||
if (retryDelay > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, retryDelay);
|
||||
});
|
||||
}
|
||||
|
||||
return $fetchRaw(context.request, {
|
||||
...context.options,
|
||||
retry: maxRetries - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const error = createFetchError(context);
|
||||
|
||||
// V8 / Node.js — clip internal frames from the error stack trace
|
||||
if (typeof Error.captureStackTrace === 'function') {
|
||||
Error.captureStackTrace(error, $fetchRaw);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// $fetchRaw — returns the full Response object with a parsed `_data` field
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const $fetchRaw: $Fetch['raw'] = async function $fetchRaw<
|
||||
T = unknown,
|
||||
R extends ResponseType = 'json',
|
||||
>(
|
||||
_request: FetchRequest,
|
||||
_options: FetchOptions<R, T> = {} as FetchOptions<R, T>,
|
||||
): Promise<FetchResponse<T>> {
|
||||
// V8: object literal with a fixed shape — V8 allocates a single hidden
|
||||
// class for all context objects created by this function.
|
||||
const context: FetchContext<T, R> = {
|
||||
request: _request,
|
||||
options: resolveFetchOptions(
|
||||
_request,
|
||||
_options,
|
||||
globalOptions.defaults as FetchOptions<R, T>,
|
||||
),
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
// Normalise method to uppercase before any hook or header logic
|
||||
if (context.options.method !== undefined) {
|
||||
context.options.method = context.options.method.toUpperCase();
|
||||
}
|
||||
|
||||
if (context.options.onRequest !== undefined) {
|
||||
await callHooks(context, context.options.onRequest);
|
||||
}
|
||||
|
||||
// URL transformations — only when request is a plain string
|
||||
if (typeof context.request === 'string') {
|
||||
if (context.options.baseURL !== undefined) {
|
||||
context.request = joinURL(context.options.baseURL, context.request);
|
||||
}
|
||||
|
||||
const query = context.options.query ?? context.options.params;
|
||||
if (query !== undefined) {
|
||||
context.request = buildURL(context.request, query);
|
||||
}
|
||||
}
|
||||
|
||||
// Body serialisation
|
||||
const method = context.options.method ?? 'GET';
|
||||
if (context.options.body !== undefined && context.options.body !== null && isPayloadMethod(method)) {
|
||||
if (isJSONSerializable(context.options.body)) {
|
||||
const contentType = context.options.headers.get('content-type');
|
||||
|
||||
if (typeof context.options.body !== 'string') {
|
||||
context.options.body
|
||||
= contentType === 'application/x-www-form-urlencoded'
|
||||
? new URLSearchParams(
|
||||
context.options.body as Record<string, string>,
|
||||
).toString()
|
||||
: JSON.stringify(context.options.body);
|
||||
}
|
||||
|
||||
if (contentType === null) {
|
||||
context.options.headers.set('content-type', 'application/json');
|
||||
}
|
||||
if (!context.options.headers.has('accept')) {
|
||||
context.options.headers.set('accept', 'application/json');
|
||||
}
|
||||
}
|
||||
else if (
|
||||
// Web Streams API body
|
||||
typeof (context.options.body as ReadableStream | null)?.pipeTo === 'function'
|
||||
) {
|
||||
if (!('duplex' in context.options)) {
|
||||
context.options.duplex = 'half';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout via AbortSignal — compose with any caller-supplied signal
|
||||
if (context.options.timeout !== undefined) {
|
||||
const timeoutSignal = AbortSignal.timeout(context.options.timeout);
|
||||
context.options.signal
|
||||
= context.options.signal !== undefined
|
||||
? AbortSignal.any([timeoutSignal, context.options.signal as AbortSignal])
|
||||
: timeoutSignal;
|
||||
}
|
||||
|
||||
// Actual fetch call
|
||||
try {
|
||||
context.response = await fetchImpl(context.request, context.options as RequestInit);
|
||||
}
|
||||
catch (err) {
|
||||
context.error = err as Error;
|
||||
|
||||
if (context.options.onRequestError !== undefined) {
|
||||
await callHooks(
|
||||
context as FetchContext<T, R> & { error: Error },
|
||||
context.options.onRequestError,
|
||||
);
|
||||
}
|
||||
|
||||
return (await onError(context)) as FetchResponse<T>;
|
||||
}
|
||||
|
||||
// Response body parsing
|
||||
const hasBody
|
||||
= context.response.body !== null
|
||||
&& !NULL_BODY_STATUSES.has(context.response.status)
|
||||
&& method !== 'HEAD';
|
||||
|
||||
if (hasBody) {
|
||||
const responseType
|
||||
= context.options.parseResponse !== undefined
|
||||
? 'json'
|
||||
: (context.options.responseType
|
||||
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
|
||||
|
||||
// V8: switch over a string constant — compiled to a jump table
|
||||
switch (responseType) {
|
||||
case 'json': {
|
||||
const text = await context.response.text();
|
||||
if (text) {
|
||||
context.response._data
|
||||
= context.options.parseResponse !== undefined
|
||||
? context.options.parseResponse(text)
|
||||
: (JSON.parse(text) as T);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stream': {
|
||||
context.response._data = context.response.body as unknown as T;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
context.response._data = (await context.response[responseType]()) as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.options.onResponse !== undefined) {
|
||||
await callHooks(
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!context.options.ignoreResponseError
|
||||
&& context.response.status >= 400
|
||||
&& context.response.status < 600
|
||||
) {
|
||||
if (context.options.onResponseError !== undefined) {
|
||||
await callHooks(
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponseError,
|
||||
);
|
||||
}
|
||||
|
||||
return (await onError(context)) as FetchResponse<T>;
|
||||
}
|
||||
|
||||
return context.response;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// $fetch — convenience wrapper that returns only the parsed data
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const $fetch = async function $fetch<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
): Promise<InferResponseType<R, T>> {
|
||||
const response = await $fetchRaw<T, R>(request, options);
|
||||
return response._data as InferResponseType<R, T>;
|
||||
} as $Fetch;
|
||||
|
||||
$fetch.raw = $fetchRaw;
|
||||
|
||||
$fetch.native = (...args: Parameters<typeof fetchImpl>) => fetchImpl(...args);
|
||||
|
||||
$fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) =>
|
||||
createFetch({
|
||||
...globalOptions,
|
||||
...customGlobalOptions,
|
||||
defaults: {
|
||||
...globalOptions.defaults,
|
||||
...customGlobalOptions.defaults,
|
||||
...defaults,
|
||||
},
|
||||
});
|
||||
|
||||
$fetch.extend = $fetch.create;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Method shortcuts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
$fetch.get = (request, options) => $fetch(request, { ...options, method: 'GET' });
|
||||
$fetch.post = (request, options) => $fetch(request, { ...options, method: 'POST' });
|
||||
$fetch.put = (request, options) => $fetch(request, { ...options, method: 'PUT' });
|
||||
$fetch.patch = (request, options) => $fetch(request, { ...options, method: 'PATCH' });
|
||||
$fetch.delete = (request, options) => $fetch(request, { ...options, method: 'DELETE' });
|
||||
$fetch.head = (request, options) => $fetchRaw(request, { ...options, method: 'HEAD' });
|
||||
|
||||
return $fetch;
|
||||
}
|
||||
|
||||
/** Resolves the inferred return value type from a ResponseType key */
|
||||
type InferResponseType<R extends ResponseType, T> = R extends keyof ResponseMap
|
||||
? ResponseMap[R]
|
||||
: T;
|
||||
46
core/fetch/src/index.ts
Normal file
46
core/fetch/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export { createFetch } from './fetch';
|
||||
export { FetchError, createFetchError } from './error';
|
||||
export {
|
||||
isPayloadMethod,
|
||||
isJSONSerializable,
|
||||
detectResponseType,
|
||||
buildURL,
|
||||
joinURL,
|
||||
callHooks,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
export type {
|
||||
$Fetch,
|
||||
CreateFetchOptions,
|
||||
Fetch,
|
||||
FetchContext,
|
||||
FetchHook,
|
||||
FetchHooks,
|
||||
FetchOptions,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
IFetchError,
|
||||
MappedResponseType,
|
||||
MaybeArray,
|
||||
MaybePromise,
|
||||
ResponseMap,
|
||||
ResponseType,
|
||||
ResolvedFetchOptions,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* @name $fetch
|
||||
* @category Fetch
|
||||
* @description Default $fetch instance backed by globalThis.fetch
|
||||
*
|
||||
* @example
|
||||
* const data = await $fetch<User>('https://api.example.com/users/1');
|
||||
*
|
||||
* @example
|
||||
* const user = await $fetch.post<User>('https://api.example.com/users', {
|
||||
* body: { name: 'Alice' },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export const $fetch = createFetch();
|
||||
237
core/fetch/src/types.ts
Normal file
237
core/fetch/src/types.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// --------------------------
|
||||
// Fetch API
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name $Fetch
|
||||
* @category Fetch
|
||||
* @description The main fetch interface with method shortcuts, raw access, and factory methods
|
||||
*/
|
||||
export interface $Fetch {
|
||||
<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
raw<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
): Promise<FetchResponse<MappedResponseType<R, T>>>;
|
||||
/** Access to the underlying native fetch function */
|
||||
native: Fetch;
|
||||
/** Create a new fetch instance with merged defaults */
|
||||
create(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Alias for create — extend this instance with new defaults */
|
||||
extend(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Shorthand for GET requests */
|
||||
get<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for POST requests */
|
||||
post<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PUT requests */
|
||||
put<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PATCH requests */
|
||||
patch<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for DELETE requests */
|
||||
delete<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for HEAD requests */
|
||||
head(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<'text', never>, 'method'>,
|
||||
): Promise<FetchResponse<never>>;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Options
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name FetchOptions
|
||||
* @category Fetch
|
||||
* @description Options for a fetch request, extending native RequestInit with additional features
|
||||
*/
|
||||
export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
|
||||
extends Omit<RequestInit, 'body'>,
|
||||
FetchHooks<T, R> {
|
||||
/** Base URL prepended to all relative request URLs */
|
||||
baseURL?: string;
|
||||
/** Request body — plain objects are automatically JSON-serialized */
|
||||
body?: BodyInit | Record<string, unknown> | unknown[] | null;
|
||||
/** Suppress throwing on 4xx/5xx responses */
|
||||
ignoreResponseError?: boolean;
|
||||
/** URL query parameters serialized and appended to the request URL */
|
||||
query?: Record<string, string | number | boolean | null | undefined>;
|
||||
/**
|
||||
* @deprecated use `query` instead
|
||||
*/
|
||||
params?: Record<string, string | number | boolean | null | undefined>;
|
||||
/** Custom response parser — overrides built-in JSON.parse */
|
||||
parseResponse?: (responseText: string) => T;
|
||||
/** Expected response format — drives body parsing */
|
||||
responseType?: R;
|
||||
/**
|
||||
* Enable duplex streaming.
|
||||
* Automatically set to "half" when a ReadableStream is used as body.
|
||||
* @see https://fetch.spec.whatwg.org/#enumdef-requestduplex
|
||||
*/
|
||||
duplex?: 'half';
|
||||
/** Request timeout in milliseconds. Uses AbortSignal.timeout internally. */
|
||||
timeout?: number;
|
||||
/** Number of retry attempts on failure, or false to disable. Defaults to 1 for non-payload methods. */
|
||||
retry?: number | false;
|
||||
/** Delay in milliseconds between retries, or a function receiving the context */
|
||||
retryDelay?: number | ((context: FetchContext<T, R>) => number);
|
||||
/**
|
||||
* HTTP status codes that trigger a retry.
|
||||
* Defaults to [408, 409, 425, 429, 500, 502, 503, 504].
|
||||
*/
|
||||
retryStatusCodes?: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @name ResolvedFetchOptions
|
||||
* @category Fetch
|
||||
* @description FetchOptions after merging defaults — headers are always a Headers instance
|
||||
*/
|
||||
export interface ResolvedFetchOptions<R extends ResponseType = 'json', T = unknown>
|
||||
extends FetchOptions<R, T> {
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name CreateFetchOptions
|
||||
* @category Fetch
|
||||
* @description Global options for createFetch
|
||||
*/
|
||||
export interface CreateFetchOptions {
|
||||
/** Default options merged into every request */
|
||||
defaults?: FetchOptions;
|
||||
/** Custom fetch implementation — defaults to globalThis.fetch */
|
||||
fetch?: Fetch;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Hooks and Context
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name FetchContext
|
||||
* @category Fetch
|
||||
* @description Mutable context object passed to all hooks and the core fetch pipeline
|
||||
*/
|
||||
export interface FetchContext<T = unknown, R extends ResponseType = 'json'> {
|
||||
request: FetchRequest;
|
||||
options: ResolvedFetchOptions<R, T>;
|
||||
response?: FetchResponse<T>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
export type MaybeArray<T> = T | readonly T[];
|
||||
|
||||
/**
|
||||
* @name FetchHook
|
||||
* @category Fetch
|
||||
* @description A function invoked at a specific point in the fetch lifecycle
|
||||
*/
|
||||
export type FetchHook<C extends FetchContext = FetchContext> = (context: C) => MaybePromise<void>;
|
||||
|
||||
/**
|
||||
* @name FetchHooks
|
||||
* @category Fetch
|
||||
* @description Lifecycle hooks for the fetch pipeline
|
||||
*/
|
||||
export interface FetchHooks<T = unknown, R extends ResponseType = 'json'> {
|
||||
/** Called before the request is sent */
|
||||
onRequest?: MaybeArray<FetchHook<FetchContext<T, R>>>;
|
||||
/** Called when the request itself throws (e.g. network error, timeout) */
|
||||
onRequestError?: MaybeArray<FetchHook<FetchContext<T, R> & { error: Error }>>;
|
||||
/** Called after a successful response is received and parsed */
|
||||
onResponse?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
|
||||
/** Called when the response status is 4xx or 5xx */
|
||||
onResponseError?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Response Types
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name ResponseMap
|
||||
* @category Fetch
|
||||
* @description Maps response type keys to their parsed value types
|
||||
*/
|
||||
export interface ResponseMap {
|
||||
blob: Blob;
|
||||
text: string;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name ResponseType
|
||||
* @category Fetch
|
||||
* @description Supported response body parsing modes
|
||||
*/
|
||||
export type ResponseType = keyof ResponseMap | 'json';
|
||||
|
||||
/**
|
||||
* @name MappedResponseType
|
||||
* @category Fetch
|
||||
* @description Resolves the response value type from a ResponseType key
|
||||
*/
|
||||
export type MappedResponseType<R extends ResponseType, T = unknown> = R extends keyof ResponseMap
|
||||
? ResponseMap[R]
|
||||
: T;
|
||||
|
||||
/**
|
||||
* @name FetchResponse
|
||||
* @category Fetch
|
||||
* @description Extended Response with a parsed `_data` field
|
||||
*/
|
||||
export interface FetchResponse<T> extends Response {
|
||||
_data?: T;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Error
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name IFetchError
|
||||
* @category Fetch
|
||||
* @description Shape of errors thrown by $fetch
|
||||
*/
|
||||
export interface IFetchError<T = unknown> extends Error {
|
||||
request?: FetchRequest;
|
||||
options?: FetchOptions;
|
||||
response?: FetchResponse<T>;
|
||||
data?: T;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
statusCode?: number;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Primitives
|
||||
// --------------------------
|
||||
|
||||
/** The native fetch function signature */
|
||||
export type Fetch = typeof globalThis.fetch;
|
||||
|
||||
/** A fetch request — URL string, URL object, or Request object */
|
||||
export type FetchRequest = RequestInfo;
|
||||
257
core/fetch/src/utils.test.ts
Normal file
257
core/fetch/src/utils.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildURL,
|
||||
callHooks,
|
||||
detectResponseType,
|
||||
isJSONSerializable,
|
||||
isPayloadMethod,
|
||||
joinURL,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
import type { FetchContext } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPayloadMethod
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isPayloadMethod', () => {
|
||||
it('returns true for payload methods', () => {
|
||||
expect(isPayloadMethod('POST')).toBe(true);
|
||||
expect(isPayloadMethod('PUT')).toBe(true);
|
||||
expect(isPayloadMethod('PATCH')).toBe(true);
|
||||
expect(isPayloadMethod('DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-payload methods', () => {
|
||||
expect(isPayloadMethod('GET')).toBe(false);
|
||||
expect(isPayloadMethod('HEAD')).toBe(false);
|
||||
expect(isPayloadMethod('OPTIONS')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isJSONSerializable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isJSONSerializable', () => {
|
||||
it('returns false for undefined', () => {
|
||||
expect(isJSONSerializable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for primitives', () => {
|
||||
expect(isJSONSerializable('hello')).toBe(true);
|
||||
expect(isJSONSerializable(42)).toBe(true);
|
||||
expect(isJSONSerializable(true)).toBe(true);
|
||||
expect(isJSONSerializable(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for functions, symbols, bigints', () => {
|
||||
expect(isJSONSerializable(() => {})).toBe(false);
|
||||
expect(isJSONSerializable(Symbol('x'))).toBe(false);
|
||||
expect(isJSONSerializable(42n)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for plain arrays', () => {
|
||||
expect(isJSONSerializable([1, 2, 3])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for ArrayBuffer-like values', () => {
|
||||
expect(isJSONSerializable(new Uint8Array([1, 2]))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for FormData and URLSearchParams', () => {
|
||||
expect(isJSONSerializable(new FormData())).toBe(false);
|
||||
expect(isJSONSerializable(new URLSearchParams())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for plain objects', () => {
|
||||
expect(isJSONSerializable({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for objects with toJSON', () => {
|
||||
expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectResponseType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectResponseType', () => {
|
||||
it('defaults to json when content-type is empty', () => {
|
||||
expect(detectResponseType('')).toBe('json');
|
||||
expect(detectResponseType()).toBe('json');
|
||||
});
|
||||
|
||||
it('detects json content types', () => {
|
||||
expect(detectResponseType('application/json')).toBe('json');
|
||||
expect(detectResponseType('application/json; charset=utf-8')).toBe('json');
|
||||
expect(detectResponseType('application/vnd.api+json')).toBe('json');
|
||||
});
|
||||
|
||||
it('detects event-stream as stream', () => {
|
||||
expect(detectResponseType('text/event-stream')).toBe('stream');
|
||||
});
|
||||
|
||||
it('detects text content types', () => {
|
||||
expect(detectResponseType('text/plain')).toBe('text');
|
||||
expect(detectResponseType('text/html')).toBe('text');
|
||||
expect(detectResponseType('application/xml')).toBe('text');
|
||||
});
|
||||
|
||||
it('falls back to blob for binary types', () => {
|
||||
expect(detectResponseType('image/png')).toBe('blob');
|
||||
expect(detectResponseType('application/octet-stream')).toBe('blob');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildURL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildURL', () => {
|
||||
it('appends query params to a clean URL', () => {
|
||||
expect(buildURL('https://api.example.com', { page: 1, limit: 20 })).toBe(
|
||||
'https://api.example.com?page=1&limit=20',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends to an existing query string with &', () => {
|
||||
expect(buildURL('https://api.example.com?foo=bar', { baz: 'qux' })).toBe(
|
||||
'https://api.example.com?foo=bar&baz=qux',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits null and undefined values', () => {
|
||||
expect(buildURL('https://api.example.com', { a: null, b: undefined, c: 'keep' })).toBe(
|
||||
'https://api.example.com?c=keep',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the URL unchanged when all params are omitted', () => {
|
||||
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// joinURL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('joinURL', () => {
|
||||
it('joins base and path correctly', () => {
|
||||
expect(joinURL('https://api.example.com/v1', '/users')).toBe(
|
||||
'https://api.example.com/v1/users',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double slashes', () => {
|
||||
expect(joinURL('https://api.example.com/v1/', '/users')).toBe(
|
||||
'https://api.example.com/v1/users',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a slash when neither side has one', () => {
|
||||
expect(joinURL('https://api.example.com/v1', 'users')).toBe(
|
||||
'https://api.example.com/v1/users',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns base when path is empty', () => {
|
||||
expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('returns base when path is "/"', () => {
|
||||
expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// callHooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('callHooks', () => {
|
||||
function makeCtx(): FetchContext {
|
||||
return {
|
||||
request: 'https://example.com',
|
||||
options: { headers: new Headers() },
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
} as FetchContext;
|
||||
}
|
||||
|
||||
it('does nothing when hooks is undefined', async () => {
|
||||
await expect(callHooks(makeCtx(), undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls a single hook', async () => {
|
||||
const calls: number[] = [];
|
||||
await callHooks(makeCtx(), () => {
|
||||
calls.push(1);
|
||||
});
|
||||
expect(calls).toEqual([1]);
|
||||
});
|
||||
|
||||
it('calls an array of hooks in order', async () => {
|
||||
const calls: number[] = [];
|
||||
await callHooks(makeCtx(), [
|
||||
() => { calls.push(1); },
|
||||
() => { calls.push(2); },
|
||||
() => { calls.push(3); },
|
||||
]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('awaits async hooks', async () => {
|
||||
const calls: number[] = [];
|
||||
await callHooks(makeCtx(), [
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
calls.push(1);
|
||||
},
|
||||
() => {
|
||||
calls.push(2);
|
||||
},
|
||||
]);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFetchOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveFetchOptions', () => {
|
||||
it('returns an object with a Headers instance', () => {
|
||||
const resolved = resolveFetchOptions('https://example.com', undefined, undefined);
|
||||
expect(resolved.headers).toBeInstanceOf(Headers);
|
||||
});
|
||||
|
||||
it('merges input and default headers (input wins)', () => {
|
||||
const resolved = resolveFetchOptions(
|
||||
'https://example.com',
|
||||
{ headers: { 'x-custom': 'input' } },
|
||||
{ headers: { 'x-custom': 'default', 'x-default-only': 'yes' } },
|
||||
);
|
||||
expect(resolved.headers.get('x-custom')).toBe('input');
|
||||
expect(resolved.headers.get('x-default-only')).toBe('yes');
|
||||
});
|
||||
|
||||
it('merges query params from defaults and input', () => {
|
||||
const resolved = resolveFetchOptions(
|
||||
'https://example.com',
|
||||
{ query: { a: '1' } },
|
||||
{ query: { b: '2' } },
|
||||
);
|
||||
expect(resolved.query).toEqual({ a: '1', b: '2' });
|
||||
});
|
||||
|
||||
it('merges params alias into query', () => {
|
||||
const resolved = resolveFetchOptions(
|
||||
'https://example.com',
|
||||
{ params: { p: '10' } },
|
||||
undefined,
|
||||
);
|
||||
expect(resolved.query).toEqual({ p: '10' });
|
||||
expect(resolved.params).toEqual({ p: '10' });
|
||||
});
|
||||
});
|
||||
282
core/fetch/src/utils.ts
Normal file
282
core/fetch/src/utils.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type {
|
||||
FetchContext,
|
||||
FetchHook,
|
||||
FetchOptions,
|
||||
FetchRequest,
|
||||
ResolvedFetchOptions,
|
||||
ResponseType,
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V8 optimisation: module-level frozen Sets avoid per-call allocations and
|
||||
// allow V8 to treat them as compile-time constants in hidden-class analysis.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** HTTP methods whose requests carry a body */
|
||||
const PAYLOAD_METHODS: ReadonlySet<string> = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']);
|
||||
|
||||
/** HTTP status codes whose responses never have a body */
|
||||
export const NULL_BODY_STATUSES: ReadonlySet<number> = /* @__PURE__ */ new Set([101, 204, 205, 304]);
|
||||
|
||||
/** Content-types treated as plain text */
|
||||
const TEXT_CONTENT_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set([
|
||||
'image/svg',
|
||||
'application/xml',
|
||||
'application/xhtml',
|
||||
'application/html',
|
||||
]);
|
||||
|
||||
/** V8: pre-compiled at module load — avoids per-call RegExp construction */
|
||||
const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Predicate helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name isPayloadMethod
|
||||
* @category Fetch
|
||||
* @description Returns true for HTTP methods that carry a request body
|
||||
*
|
||||
* V8: function is monomorphic — always called with an uppercase string.
|
||||
*
|
||||
* @param {string} method - Uppercase HTTP method string
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function isPayloadMethod(method: string): boolean {
|
||||
return PAYLOAD_METHODS.has(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isJSONSerializable
|
||||
* @category Fetch
|
||||
* @description Returns true when a value can be serialised with JSON.stringify
|
||||
*
|
||||
* V8: typeof checks are ordered from most-common to least-common to maximise
|
||||
* the probability of an early return and keep the IC monomorphic.
|
||||
*
|
||||
* @param {unknown} value - Any value
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function isJSONSerializable(value: unknown): boolean {
|
||||
if (value === undefined) return false;
|
||||
|
||||
const type = typeof value;
|
||||
|
||||
// Fast path — primitives are always serialisable
|
||||
if (type === 'string' || type === 'number' || type === 'boolean' || value === null) return true;
|
||||
|
||||
// Non-object types (bigint, function, symbol) are not serialisable
|
||||
if (type !== 'object') return false;
|
||||
|
||||
// Arrays are serialisable
|
||||
if (Array.isArray(value)) return true;
|
||||
|
||||
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
|
||||
if ((value as Record<string, unknown>).buffer !== undefined) return false;
|
||||
|
||||
// FormData and URLSearchParams should not be auto-serialised
|
||||
if (value instanceof FormData || value instanceof URLSearchParams) return false;
|
||||
|
||||
// Plain objects or objects with a custom toJSON
|
||||
const ctor = (value as object).constructor;
|
||||
return (
|
||||
ctor === undefined
|
||||
|| ctor === Object
|
||||
|| typeof (value as Record<string, unknown>).toJSON === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response type detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name detectResponseType
|
||||
* @category Fetch
|
||||
* @description Infers the response body parsing strategy from a Content-Type header value
|
||||
*
|
||||
* @param {string} [contentType] - Value of the Content-Type response header
|
||||
* @returns {ResponseType}
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function detectResponseType(contentType = ''): ResponseType {
|
||||
if (!contentType) return 'json';
|
||||
|
||||
// V8: split once and reuse — avoids calling split multiple times
|
||||
const type = contentType.split(';')[0] ?? '';
|
||||
|
||||
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
|
||||
if (type === 'text/event-stream') return 'stream';
|
||||
if (TEXT_CONTENT_TYPES.has(type) || type.startsWith('text/')) return 'text';
|
||||
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name buildURL
|
||||
* @category Fetch
|
||||
* @description Appends serialised query parameters to a URL string
|
||||
*
|
||||
* Null and undefined values are omitted. Existing query strings are preserved.
|
||||
*
|
||||
* @param {string} url - Base URL (may already contain a query string)
|
||||
* @param {Record<string, string | number | boolean | null | undefined>} query - Parameters to append
|
||||
* @returns {string} URL with query string
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function buildURL(
|
||||
url: string,
|
||||
query: Record<string, string | number | boolean | null | undefined>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const key of Object.keys(query)) {
|
||||
const value = query[key];
|
||||
if (value !== null && value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
if (!qs) return url;
|
||||
|
||||
return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name joinURL
|
||||
* @category Fetch
|
||||
* @description Joins a base URL with a relative path, normalising the slash boundary
|
||||
*
|
||||
* @param {string} base - Base URL (e.g. "https://api.example.com/v1")
|
||||
* @param {string} path - Relative path (e.g. "/users")
|
||||
* @returns {string} Joined URL
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function joinURL(base: string, path: string): string {
|
||||
if (!path || path === '/') return base;
|
||||
|
||||
const baseEnds = base.endsWith('/');
|
||||
const pathStarts = path.startsWith('/');
|
||||
|
||||
if (baseEnds && pathStarts) return `${base}${path.slice(1)}`;
|
||||
if (!baseEnds && !pathStarts) return `${base}/${path}`;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name resolveFetchOptions
|
||||
* @category Fetch
|
||||
* @description Merges per-request options with global defaults
|
||||
*
|
||||
* V8: the returned object always has the same property set (fixed shape),
|
||||
* which lets V8 reuse its hidden class across all calls.
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown>(
|
||||
request: FetchRequest,
|
||||
input: FetchOptions<R, T> | undefined,
|
||||
defaults: FetchOptions<R, T> | undefined,
|
||||
): ResolvedFetchOptions<R, T> {
|
||||
const headers = mergeHeaders(
|
||||
input?.headers ?? (request as Request)?.headers,
|
||||
defaults?.headers,
|
||||
);
|
||||
|
||||
let query: Record<string, string | number | boolean | null | undefined> | undefined;
|
||||
if (
|
||||
defaults?.query !== undefined
|
||||
|| defaults?.params !== undefined
|
||||
|| input?.params !== undefined
|
||||
|| input?.query !== undefined
|
||||
) {
|
||||
query = {
|
||||
...defaults?.params,
|
||||
...defaults?.query,
|
||||
...input?.params,
|
||||
...input?.query,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...input,
|
||||
query,
|
||||
params: query,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two HeadersInit sources into a single Headers instance.
|
||||
* Input headers override default headers.
|
||||
*
|
||||
* V8: avoids constructing an intermediate Headers when defaults are absent.
|
||||
*/
|
||||
function mergeHeaders(
|
||||
input: HeadersInit | undefined,
|
||||
defaults: HeadersInit | undefined,
|
||||
): Headers {
|
||||
if (defaults === undefined) {
|
||||
return new Headers(input);
|
||||
}
|
||||
|
||||
const merged = new Headers(defaults);
|
||||
|
||||
if (input !== undefined) {
|
||||
const src = input instanceof Headers ? input : new Headers(input);
|
||||
for (const [key, value] of src) {
|
||||
merged.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name callHooks
|
||||
* @category Fetch
|
||||
* @description Invokes one or more lifecycle hooks with the given context
|
||||
*
|
||||
* V8: the single-hook path avoids Array creation; the Array path uses a
|
||||
* for-loop with a cached length to stay monomorphic inside the loop body.
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export async function callHooks<C extends FetchContext = FetchContext>(
|
||||
context: C,
|
||||
hooks: FetchHook<C> | readonly FetchHook<C>[] | undefined,
|
||||
): Promise<void> {
|
||||
if (hooks === undefined) return;
|
||||
|
||||
if (Array.isArray(hooks)) {
|
||||
const len = hooks.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
await (hooks as Array<FetchHook<C>>)[i]!(context);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await (hooks as FetchHook<C>)(context);
|
||||
}
|
||||
}
|
||||
3
core/fetch/tsconfig.json
Normal file
3
core/fetch/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
7
core/fetch/tsdown.config.ts
Normal file
7
core/fetch/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
7
core/fetch/vitest.config.ts
Normal file
7
core/fetch/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -28,14 +28,24 @@
|
||||
],
|
||||
"exports": {
|
||||
"./browsers": {
|
||||
"types": "./dist/browsers.d.ts",
|
||||
"import": "./dist/browsers.js",
|
||||
"require": "./dist/browsers.cjs"
|
||||
"import": {
|
||||
"types": "./dist/browsers.d.mts",
|
||||
"default": "./dist/browsers.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/browsers.d.cts",
|
||||
"default": "./dist/browsers.cjs"
|
||||
}
|
||||
},
|
||||
"./multi": {
|
||||
"types": "./dist/multi.d.ts",
|
||||
"import": "./dist/multi.js",
|
||||
"require": "./dist/multi.cjs"
|
||||
"import": {
|
||||
"types": "./dist/multi.d.mts",
|
||||
"default": "./dist/multi.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/multi.d.cts",
|
||||
"default": "./dist/multi.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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);
|
||||
}
|
||||
};
|
||||
}
|
||||
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { afterEach, describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getActiveElement,
|
||||
getTabbableCandidates,
|
||||
getTabbableEdges,
|
||||
focusFirst,
|
||||
focus,
|
||||
isHidden,
|
||||
isSelectableInput,
|
||||
AUTOFOCUS_ON_MOUNT,
|
||||
AUTOFOCUS_ON_UNMOUNT,
|
||||
EVENT_OPTIONS,
|
||||
} from '.';
|
||||
|
||||
function createContainer(html: string): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('constants', () => {
|
||||
it('exports correct event names', () => {
|
||||
expect(AUTOFOCUS_ON_MOUNT).toBe('focusScope.autoFocusOnMount');
|
||||
expect(AUTOFOCUS_ON_UNMOUNT).toBe('focusScope.autoFocusOnUnmount');
|
||||
});
|
||||
|
||||
it('exports correct event options', () => {
|
||||
expect(EVENT_OPTIONS).toEqual({ bubbles: false, cancelable: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveElement', () => {
|
||||
it('returns document.body when nothing is focused', () => {
|
||||
const active = getActiveElement();
|
||||
expect(active).toBe(document.body);
|
||||
});
|
||||
|
||||
it('returns the focused element', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
expect(getActiveElement()).toBe(input);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTabbableCandidates', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('returns focusable elements with tabindex >= 0', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" />
|
||||
<button>Click</button>
|
||||
<a href="#">Link</a>
|
||||
<div tabindex="0">Div</div>
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(4);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips disabled elements', () => {
|
||||
const container = createContainer(`
|
||||
<button disabled>Disabled</button>
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
expect(candidates[0]!.tagName).toBe('INPUT');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips hidden inputs', () => {
|
||||
const container = createContainer(`
|
||||
<input type="hidden" />
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
expect((candidates[0] as HTMLInputElement).type).toBe('text');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips elements with hidden attribute', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" hidden />
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns empty array for container with no focusable elements', () => {
|
||||
const container = createContainer(`
|
||||
<div>Just text</div>
|
||||
<span>More text</span>
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(0);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTabbableEdges', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('returns first and last tabbable elements', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" data-testid="first" />
|
||||
<button>Middle</button>
|
||||
<input type="text" data-testid="last" />
|
||||
`);
|
||||
|
||||
const { first, last } = getTabbableEdges(container);
|
||||
expect(first?.getAttribute('data-testid')).toBe('first');
|
||||
expect(last?.getAttribute('data-testid')).toBe('last');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns undefined for both when no tabbable elements', () => {
|
||||
const container = createContainer(`<div>no focusable</div>`);
|
||||
|
||||
const { first, last } = getTabbableEdges(container);
|
||||
expect(first).toBeUndefined();
|
||||
expect(last).toBeUndefined();
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusFirst', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('focuses the first element in the list', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" data-testid="a" />
|
||||
<input type="text" data-testid="b" />
|
||||
`);
|
||||
|
||||
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
|
||||
focusFirst(candidates);
|
||||
|
||||
expect(document.activeElement).toBe(candidates[0]);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns true when focus changed', () => {
|
||||
const container = createContainer(`<input type="text" />`);
|
||||
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
|
||||
|
||||
const result = focusFirst(candidates);
|
||||
expect(result).toBe(true);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns false when no candidate receives focus', () => {
|
||||
const result = focusFirst([]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus', () => {
|
||||
it('does nothing when element is null', () => {
|
||||
expect(() => focus(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('focuses the given element', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
focus(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('calls select on input when select=true', () => {
|
||||
const input = document.createElement('input');
|
||||
input.value = 'hello';
|
||||
document.body.appendChild(input);
|
||||
|
||||
focus(input, { select: true });
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSelectableInput', () => {
|
||||
it('returns true for input elements', () => {
|
||||
const input = document.createElement('input');
|
||||
expect(isSelectableInput(input)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-input elements', () => {
|
||||
const div = document.createElement('div');
|
||||
expect(isSelectableInput(div)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHidden', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('detects elements with visibility: hidden', () => {
|
||||
const container = createContainer('');
|
||||
const el = document.createElement('div');
|
||||
el.style.visibility = 'hidden';
|
||||
container.appendChild(el);
|
||||
|
||||
expect(isHidden(el)).toBe(true);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('detects elements with display: none', () => {
|
||||
const container = createContainer('');
|
||||
const el = document.createElement('div');
|
||||
el.style.display = 'none';
|
||||
container.appendChild(el);
|
||||
|
||||
expect(isHidden(el)).toBe(true);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns false for visible elements', () => {
|
||||
const container = createContainer('');
|
||||
const el = document.createElement('div');
|
||||
container.appendChild(el);
|
||||
|
||||
expect(isHidden(el, container)).toBe(false);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
168
core/platform/src/browsers/focusScope/index.ts
Normal file
168
core/platform/src/browsers/focusScope/index.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type FocusableTarget = HTMLElement | { focus: () => void };
|
||||
|
||||
export const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
|
||||
export const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
|
||||
export const EVENT_OPTIONS = { bubbles: false, cancelable: true };
|
||||
|
||||
/**
|
||||
* @name getActiveElement
|
||||
* @category Browsers
|
||||
* @description Returns the active element of the document (or shadow root)
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getActiveElement(doc: Document | ShadowRoot = document): HTMLElement | null {
|
||||
let active = doc.activeElement as HTMLElement | null;
|
||||
|
||||
while (active?.shadowRoot)
|
||||
active = active.shadowRoot.activeElement as HTMLElement | null;
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isSelectableInput
|
||||
* @category Browsers
|
||||
* @description Checks if an element is an input element with a select method
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isSelectableInput(element: unknown): element is FocusableTarget & { select: () => void } {
|
||||
return element instanceof HTMLInputElement && 'select' in element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name focus
|
||||
* @category Browsers
|
||||
* @description Focuses an element without scrolling. Optionally calls select on input elements.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function focus(element?: FocusableTarget | null, { select = false } = {}) {
|
||||
if (element && element.focus) {
|
||||
const previouslyFocused = getActiveElement();
|
||||
|
||||
element.focus({ preventScroll: true });
|
||||
|
||||
if (element !== previouslyFocused && isSelectableInput(element) && select) {
|
||||
element.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name focusFirst
|
||||
* @category Browsers
|
||||
* @description Attempts to focus the first element from a list of candidates. Stops when focus actually moves.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function focusFirst(candidates: HTMLElement[], { select = false } = {}): boolean {
|
||||
const previouslyFocused = getActiveElement();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
focus(candidate, { select });
|
||||
|
||||
if (getActiveElement() !== previouslyFocused)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTabbableCandidates
|
||||
* @category Browsers
|
||||
* @description Collects all tabbable candidates via TreeWalker (faster than querySelectorAll).
|
||||
* This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
||||
acceptNode: (node: HTMLElement) => {
|
||||
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
|
||||
|
||||
if ((node as any).disabled || node.hidden || isHiddenInput)
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
|
||||
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
});
|
||||
|
||||
while (walker.nextNode())
|
||||
nodes.push(walker.currentNode as HTMLElement);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isHidden
|
||||
* @category Browsers
|
||||
* @description Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isHidden(node: HTMLElement, upTo?: HTMLElement): boolean {
|
||||
const style = getComputedStyle(node);
|
||||
|
||||
if (style.visibility === 'hidden' || style.display === 'none')
|
||||
return true;
|
||||
|
||||
while (node.parentElement) {
|
||||
node = node.parentElement;
|
||||
|
||||
if (upTo !== undefined && node === upTo)
|
||||
return false;
|
||||
|
||||
if (getComputedStyle(node).display === 'none')
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name findFirstVisible
|
||||
* @category Browsers
|
||||
* @description Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function findFirstVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
|
||||
for (const element of elements) {
|
||||
if (!isHidden(element, container))
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name findLastVisible
|
||||
* @category Browsers
|
||||
* @description Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function findLastVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
if (!isHidden(elements[i]!, container))
|
||||
return elements[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTabbableEdges
|
||||
* @category Browsers
|
||||
* @description Returns the first and last tabbable elements inside a container
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getTabbableEdges(container: HTMLElement): { first: HTMLElement | undefined; last: HTMLElement | undefined } {
|
||||
const candidates = getTabbableCandidates(container);
|
||||
const first = findFirstVisible(candidates, container);
|
||||
const last = findLastVisible(candidates, container);
|
||||
|
||||
return { first, last };
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
|
||||
@@ -28,9 +28,14 @@
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
1336
pnpm-lock.yaml
generated
1336
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'configs/oxlint/vitest.config.ts',
|
||||
'core/fetch/vitest.config.ts',
|
||||
'core/stdlib/vitest.config.ts',
|
||||
'core/platform/vitest.config.ts',
|
||||
'vue/toolkit/vitest.config.ts',
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
|
||||
import { defineConfig } from '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": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -44,10 +49,16 @@
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"axe-core": "^4.11.1",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
"tsdown": "catalog:",
|
||||
"unplugin-vue": "^7.1.1",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "^0.0.9",
|
||||
"@robonen/vue": "workspace:*",
|
||||
"@vue/shared": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import {
|
||||
provideAppConfig,
|
||||
provideConfig,
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
setup() {
|
||||
provideConfig({
|
||||
dir: 'rtl',
|
||||
teleportTarget: '#app',
|
||||
});
|
||||
},
|
||||
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');
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
41
vue/primitives/src/config-provider/context.ts
Normal file
41
vue/primitives/src/config-provider/context.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||
import { ref, shallowRef, toValue } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export type Direction = 'ltr' | 'rtl';
|
||||
|
||||
export interface ConfigContext {
|
||||
dir: Ref<Direction>;
|
||||
teleportTarget: ShallowRef<string | HTMLElement>;
|
||||
}
|
||||
|
||||
export interface ConfigOptions {
|
||||
dir?: MaybeRefOrGetter<Direction>;
|
||||
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
|
||||
dir: 'ltr',
|
||||
teleportTarget: 'body',
|
||||
};
|
||||
|
||||
const ConfigCtx = useContextFactory<ConfigContext>('ConfigContext');
|
||||
|
||||
function resolveContext(options?: ConfigOptions): ConfigContext {
|
||||
return {
|
||||
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
|
||||
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';
|
||||
93
vue/primitives/src/focus-scope/FocusScope.vue
Normal file
93
vue/primitives/src/focus-scope/FocusScope.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export type FocusScopeEmits = {
|
||||
/** Автофокус при монтировании. Можно предотвратить через `event.preventDefault()`. */
|
||||
mountAutoFocus: [event: Event];
|
||||
/** Автофокус при размонтировании. Можно предотвратить через `event.preventDefault()`. */
|
||||
unmountAutoFocus: [event: Event];
|
||||
};
|
||||
|
||||
export interface FocusScopeProps extends PrimitiveProps {
|
||||
/**
|
||||
* Зациклить Tab/Shift+Tab: с последнего элемента — на первый и наоборот.
|
||||
* @default false
|
||||
*/
|
||||
loop?: boolean;
|
||||
|
||||
/**
|
||||
* Удерживать фокус внутри scope — фокус не может покинуть контейнер
|
||||
* через клавиатуру, указатель или программный вызов.
|
||||
* @default false
|
||||
*/
|
||||
trapped?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { focus, getActiveElement, getTabbableEdges } from '@robonen/platform/browsers';
|
||||
import type { FocusScopeAPI } from './stack';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useAutoFocus } from './useAutoFocus';
|
||||
import { useFocusTrap } from './useFocusTrap';
|
||||
import { useTemplateRef } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<FocusScopeProps>(), {
|
||||
loop: false,
|
||||
trapped: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<FocusScopeEmits>();
|
||||
|
||||
const containerRef = useTemplateRef<HTMLElement>('containerRef');
|
||||
|
||||
const focusScope: FocusScopeAPI = {
|
||||
paused: false,
|
||||
pause() { this.paused = true; },
|
||||
resume() { this.paused = false; },
|
||||
};
|
||||
|
||||
useFocusTrap(containerRef, focusScope, () => props.trapped);
|
||||
useAutoFocus(
|
||||
containerRef,
|
||||
focusScope,
|
||||
ev => emit('mountAutoFocus', ev),
|
||||
ev => emit('unmountAutoFocus', ev),
|
||||
);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!props.loop && !props.trapped) return;
|
||||
if (focusScope.paused) return;
|
||||
|
||||
const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||
const focusedElement = getActiveElement();
|
||||
|
||||
if (!isTabKey || !focusedElement) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const { first, last } = getTabbableEdges(container);
|
||||
|
||||
if (!first || !last) {
|
||||
if (focusedElement === container) event.preventDefault();
|
||||
}
|
||||
else if (!event.shiftKey && focusedElement === last) {
|
||||
event.preventDefault();
|
||||
if (props.loop) focus(first, { select: true });
|
||||
}
|
||||
else if (event.shiftKey && focusedElement === first) {
|
||||
event.preventDefault();
|
||||
if (props.loop) focus(last, { select: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
ref="containerRef"
|
||||
tabindex="-1"
|
||||
:as="as"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
450
vue/primitives/src/focus-scope/__test__/FocusScope.test.ts
Normal file
450
vue/primitives/src/focus-scope/__test__/FocusScope.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import FocusScope from '../FocusScope.vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
function createFocusScope(props: Record<string, unknown> = {}, slots?: Record<string, () => any>) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
FocusScope,
|
||||
props,
|
||||
slots ?? {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'first' }),
|
||||
h('input', { type: 'text', 'data-testid': 'second' }),
|
||||
h('input', { type: 'text', 'data-testid': 'third' }),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
describe('FocusScope', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('renders slot content inside a div with tabindex="-1"', () => {
|
||||
const wrapper = createFocusScope();
|
||||
|
||||
expect(wrapper.find('[tabindex="-1"]').exists()).toBe(true);
|
||||
expect(wrapper.findAll('input').length).toBe(3);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders with custom element via as prop', () => {
|
||||
const wrapper = createFocusScope({ as: 'section' });
|
||||
|
||||
expect(wrapper.find('section').exists()).toBe(true);
|
||||
expect(wrapper.find('section').attributes('tabindex')).toBe('-1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('auto-focuses first tabbable element on mount', async () => {
|
||||
const wrapper = createFocusScope();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits mountAutoFocus on mount', async () => {
|
||||
const onMountAutoFocus = vi.fn();
|
||||
const wrapper = createFocusScope({ onMountAutoFocus });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(onMountAutoFocus).toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits unmountAutoFocus on unmount', async () => {
|
||||
const onUnmountAutoFocus = vi.fn();
|
||||
const wrapper = createFocusScope({ onUnmountAutoFocus });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
expect(onUnmountAutoFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('focuses container when no tabbable elements exist', async () => {
|
||||
const wrapper = createFocusScope({}, {
|
||||
default: () => h('span', 'no focusable elements'),
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]').element;
|
||||
expect(document.activeElement).toBe(container);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope loop', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('wraps focus from last to first on Tab when loop=true', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab' });
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('wraps focus from first to last on Shift+Tab when loop=true', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
firstInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab', shiftKey: true });
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not wrap focus when loop=false', async () => {
|
||||
const wrapper = createFocusScope({ loop: false });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab' });
|
||||
|
||||
// Focus should remain on the last element (no wrapping)
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ignores non-Tab keys', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Enter' });
|
||||
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ignores Tab with modifier keys', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab', ctrlKey: true });
|
||||
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope trapped', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('returns focus to last focused element when focus leaves', async () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h('button', { id: 'outside' }, 'outside'),
|
||||
h(FocusScope, { trapped: true }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inside' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
// Simulate focus moving outside
|
||||
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
|
||||
outsideButton.focus();
|
||||
|
||||
// The focusin event handler should bring focus back
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('activates trap when trapped changes from false to true', async () => {
|
||||
const trapped = ref(false);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h('button', { id: 'outside' }, 'outside'),
|
||||
h(FocusScope, { trapped: trapped.value }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inside' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Not trapped yet — focus can leave
|
||||
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
|
||||
outsideButton.focus();
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
|
||||
// Enable trap
|
||||
trapped.value = true;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Focus inside first
|
||||
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
|
||||
insideInput.focus();
|
||||
await nextTick();
|
||||
|
||||
// Try to leave — should be pulled back
|
||||
outsideButton.focus();
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('deactivates trap when trapped changes from true to false', async () => {
|
||||
const trapped = ref(true);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h('button', { id: 'outside' }, 'outside'),
|
||||
h(FocusScope, { trapped: trapped.value }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inside' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
// Disable trap
|
||||
trapped.value = false;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Focus can now leave
|
||||
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
|
||||
outsideButton.focus();
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('refocuses container when focused element is removed from DOM', async () => {
|
||||
const showChild = ref(true);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(FocusScope, { trapped: true }, {
|
||||
default: () =>
|
||||
showChild.value
|
||||
? [h('input', { type: 'text', 'data-testid': 'removable' })]
|
||||
: [h('span', 'empty')],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const input = wrapper.find('[data-testid="removable"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
// Remove the focused element
|
||||
showChild.value = false;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// MutationObserver should refocus the container
|
||||
const container = wrapper.find('[tabindex="-1"]').element;
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(container);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope preventAutoFocus', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('prevents auto-focus on mount via event.preventDefault()', async () => {
|
||||
const wrapper = createFocusScope({
|
||||
onMountAutoFocus: (e: Event) => e.preventDefault(),
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element;
|
||||
// Focus should not have been moved to the first input
|
||||
expect(document.activeElement).not.toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('prevents focus restore on unmount via event.preventDefault()', async () => {
|
||||
const wrapper = createFocusScope({
|
||||
onUnmountAutoFocus: (e: Event) => e.preventDefault(),
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
// Focus should NOT have been restored to body
|
||||
expect(document.activeElement).not.toBe(firstInput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope nested stacks', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('pauses outer scope when inner scope mounts, resumes on inner unmount', async () => {
|
||||
const showInner = ref(false);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(FocusScope, { trapped: true }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'outer-input' }),
|
||||
showInner.value
|
||||
? h(FocusScope, { trapped: true }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inner-input' }),
|
||||
],
|
||||
})
|
||||
: null,
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Outer scope auto-focused
|
||||
const outerInput = wrapper.find('[data-testid="outer-input"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(outerInput);
|
||||
|
||||
// Mount inner scope
|
||||
showInner.value = true;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Inner scope should auto-focus its content
|
||||
const innerInput = wrapper.find('[data-testid="inner-input"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(innerInput);
|
||||
|
||||
// Unmount inner scope
|
||||
showInner.value = false;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Focus should return to outer scope's previously focused element
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(outerInput);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
67
vue/primitives/src/focus-scope/__test__/a11y.test.ts
Normal file
67
vue/primitives/src/focus-scope/__test__/a11y.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import FocusScope from '../FocusScope.vue';
|
||||
import axe from 'axe-core';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
async function checkA11y(element: Element) {
|
||||
const results = await axe.run(element);
|
||||
return results.violations;
|
||||
}
|
||||
|
||||
function createFocusScope(props: Record<string, unknown> = {}) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
FocusScope,
|
||||
props,
|
||||
{
|
||||
default: () => [
|
||||
h('button', { type: 'button' }, 'First'),
|
||||
h('button', { type: 'button' }, 'Second'),
|
||||
h('button', { type: 'button' }, 'Third'),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
describe('FocusScope a11y', () => {
|
||||
it('has no axe violations with default props', async () => {
|
||||
const wrapper = createFocusScope();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations with loop enabled', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations with trapped enabled', async () => {
|
||||
const wrapper = createFocusScope({ trapped: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
3
vue/primitives/src/focus-scope/index.ts
Normal file
3
vue/primitives/src/focus-scope/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FocusScope } from './FocusScope.vue';
|
||||
|
||||
export type { FocusScopeEmits, FocusScopeProps } from './FocusScope.vue';
|
||||
29
vue/primitives/src/focus-scope/stack.ts
Normal file
29
vue/primitives/src/focus-scope/stack.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface FocusScopeAPI {
|
||||
paused: boolean;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
const stack: FocusScopeAPI[] = [];
|
||||
|
||||
export function createFocusScopesStack() {
|
||||
return {
|
||||
add(focusScope: FocusScopeAPI) {
|
||||
const current = stack.at(-1);
|
||||
if (focusScope !== current) current?.pause();
|
||||
|
||||
// Remove if already in stack (deduplicate), then push to top
|
||||
const index = stack.indexOf(focusScope);
|
||||
if (index !== -1) stack.splice(index, 1);
|
||||
|
||||
stack.push(focusScope);
|
||||
},
|
||||
|
||||
remove(focusScope: FocusScopeAPI) {
|
||||
const index = stack.indexOf(focusScope);
|
||||
if (index !== -1) stack.splice(index, 1);
|
||||
|
||||
stack.at(-1)?.resume();
|
||||
},
|
||||
};
|
||||
}
|
||||
63
vue/primitives/src/focus-scope/useAutoFocus.ts
Normal file
63
vue/primitives/src/focus-scope/useAutoFocus.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AUTOFOCUS_ON_MOUNT,
|
||||
AUTOFOCUS_ON_UNMOUNT,
|
||||
EVENT_OPTIONS,
|
||||
focus,
|
||||
focusFirst,
|
||||
getActiveElement,
|
||||
getTabbableCandidates,
|
||||
} from '@robonen/platform/browsers';
|
||||
import type { FocusScopeAPI } from './stack';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { createFocusScopesStack } from './stack';
|
||||
import { watchPostEffect } from 'vue';
|
||||
|
||||
function dispatchCancelableEvent(
|
||||
container: HTMLElement,
|
||||
eventName: string,
|
||||
handler: (ev: Event) => void,
|
||||
): CustomEvent {
|
||||
const event = new CustomEvent(eventName, EVENT_OPTIONS);
|
||||
container.addEventListener(eventName, handler);
|
||||
container.dispatchEvent(event);
|
||||
container.removeEventListener(eventName, handler);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function useAutoFocus(
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
focusScope: FocusScopeAPI,
|
||||
onMountAutoFocus: (ev: Event) => void,
|
||||
onUnmountAutoFocus: (ev: Event) => void,
|
||||
) {
|
||||
const stack = createFocusScopesStack();
|
||||
|
||||
watchPostEffect((onCleanup) => {
|
||||
const el = container.value;
|
||||
if (!el) return;
|
||||
|
||||
stack.add(focusScope);
|
||||
const previouslyFocusedElement = getActiveElement();
|
||||
|
||||
if (!el.contains(previouslyFocusedElement)) {
|
||||
const event = dispatchCancelableEvent(el, AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
focusFirst(getTabbableCandidates(el), { select: true });
|
||||
|
||||
if (getActiveElement() === previouslyFocusedElement)
|
||||
focus(el);
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
const event = dispatchCancelableEvent(el, AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
focus(previouslyFocusedElement ?? document.body, { select: true });
|
||||
}
|
||||
|
||||
stack.remove(focusScope);
|
||||
});
|
||||
});
|
||||
}
|
||||
60
vue/primitives/src/focus-scope/useFocusTrap.ts
Normal file
60
vue/primitives/src/focus-scope/useFocusTrap.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import { shallowRef, toValue, watchPostEffect } from 'vue';
|
||||
import type { FocusScopeAPI } from './stack';
|
||||
import { focus } from '@robonen/platform/browsers';
|
||||
|
||||
export function useFocusTrap(
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
focusScope: FocusScopeAPI,
|
||||
trapped: MaybeRefOrGetter<boolean>,
|
||||
) {
|
||||
const lastFocusedElement = shallowRef<HTMLElement | null>(null);
|
||||
|
||||
watchPostEffect((onCleanup) => {
|
||||
const el = container.value;
|
||||
if (!toValue(trapped) || !el) return;
|
||||
|
||||
function handleFocusIn(event: FocusEvent) {
|
||||
if (focusScope.paused || !el) return;
|
||||
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (el.contains(target)) {
|
||||
lastFocusedElement.value = target;
|
||||
}
|
||||
else {
|
||||
focus(lastFocusedElement.value, { select: true });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOut(event: FocusEvent) {
|
||||
if (focusScope.paused || !el) return;
|
||||
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||
|
||||
// null relatedTarget = браузер/вкладка потеряла фокус или элемент удалён из DOM.
|
||||
if (relatedTarget === null) return;
|
||||
|
||||
if (!el.contains(relatedTarget)) {
|
||||
focus(lastFocusedElement.value, { select: true });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMutations() {
|
||||
if (!el!.contains(lastFocusedElement.value))
|
||||
focus(el!);
|
||||
}
|
||||
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
document.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
const observer = new MutationObserver(handleMutations);
|
||||
observer.observe(el, { childList: true, subtree: true });
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
document.removeEventListener('focusout', handleFocusOut);
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export * from './config-provider';
|
||||
export * from './primitive';
|
||||
export * from './presence';
|
||||
export * from './pagination';
|
||||
export * from './focus-scope';
|
||||
|
||||
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 { useForwardExpose, useOffsetPagination } 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, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot,
|
||||
} 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, expect, it } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import axe from 'axe-core';
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot,
|
||||
} 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, expect, it } from 'vitest';
|
||||
import { PaginationItemType, getRange, transform } 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, expect, it, 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 {
|
||||
dispatchAnimationEvent,
|
||||
getAnimationName,
|
||||
onAnimationSettle,
|
||||
shouldSuspendUnmount,
|
||||
} 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 { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
|
||||
import {
|
||||
dispatchAnimationEvent,
|
||||
getAnimationName,
|
||||
onAnimationSettle,
|
||||
shouldSuspendUnmount,
|
||||
} from '@robonen/platform/browsers';
|
||||
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
||||
import type { MaybeElement } from '@robonen/vue';
|
||||
|
||||
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 { h } from 'vue';
|
||||
import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
|
||||
import { h, mergeProps } from 'vue';
|
||||
import { Slot } from './Slot';
|
||||
|
||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||
@@ -8,10 +8,12 @@ export interface PrimitiveProps {
|
||||
as?: keyof IntrinsicElementAttributes | Component;
|
||||
}
|
||||
|
||||
export function Primitive(props: PrimitiveProps, ctx: FunctionalComponentContext) {
|
||||
return props.as === 'template'
|
||||
? h(Slot, ctx.attrs, ctx.slots)
|
||||
: h(props.as!, ctx.attrs, ctx.slots);
|
||||
export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record<string, unknown>, ctx: FunctionalComponentContext) {
|
||||
const { as, ...delegatedProps } = props;
|
||||
|
||||
return as === 'template'
|
||||
? h(Slot, mergeProps(ctx.attrs, delegatedProps), ctx.slots)
|
||||
: h(as!, mergeProps(ctx.attrs, delegatedProps), ctx.slots);
|
||||
}
|
||||
|
||||
Primitive.props = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { cloneVNode, Comment, createVNode, h } from 'vue';
|
||||
import { Comment, cloneVNode, createVNode, h } from 'vue';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
// -- Attribute sets of increasing size --
|
||||
@@ -9,13 +9,13 @@ const attrs1 = { class: 'a' };
|
||||
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
|
||||
|
||||
const attrs15 = {
|
||||
'class': 'a',
|
||||
'id': 'b',
|
||||
'style': { color: 'red' },
|
||||
'onClick': () => {},
|
||||
'role': 'button',
|
||||
'tabindex': '0',
|
||||
'title': 'tip',
|
||||
class: 'a',
|
||||
id: 'b',
|
||||
style: { color: 'red' },
|
||||
onClick: () => {},
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: 'tip',
|
||||
'data-a': '1',
|
||||
'data-b': '2',
|
||||
'data-c': '3',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PrimitiveProps } from '..';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createVNode, Comment, h, defineComponent, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Comment, createVNode, defineComponent, h, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
@@ -224,7 +224,7 @@ describe(Primitive, () => {
|
||||
it('merges attrs onto the slotted child in template mode', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
attrs: { 'class': 'merged', 'data-testid': 'slot' },
|
||||
attrs: { class: 'merged', 'data-testid': 'slot' },
|
||||
slots: { default: () => h('div', 'child') },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h, render } from 'vue';
|
||||
import { Comment, Fragment, createVNode, h, render } from 'vue';
|
||||
import { PatchFlags } from '@vue/shared';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Comment, Fragment, createVNode, h } from 'vue';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
describe(getRawChildren, () => {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"strictTemplates": true,
|
||||
"fallthroughAttributes": true,
|
||||
"inferTemplateDollarAttrs": true,
|
||||
"inferTemplateDollarEl": true,
|
||||
"inferTemplateDollarRefs": true
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
import Vue from 'unplugin-vue/rolldown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
plugins: [Vue({ isProduction: true })],
|
||||
dts: { vue: true },
|
||||
deps: {
|
||||
neverBundle: ['vue'],
|
||||
alwaysBundle: [/^@robonen\//, '@vue/shared'],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Vue from 'unplugin-vue/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [Vue()],
|
||||
define: {
|
||||
__DEV__: 'true',
|
||||
},
|
||||
|
||||
@@ -26,9 +26,14 @@
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
|
||||
import { toValue } from 'vue';
|
||||
|
||||
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 MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
|
||||
|
||||
Reference in New Issue
Block a user