mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
@@ -11,7 +11,7 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "./apps/vhs"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"bun": ">=1.1.27"
|
||||
},
|
||||
@@ -21,6 +21,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@types/bun": "^1.1.10"
|
||||
"@types/bun": "^1.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.10",
|
||||
"@types/node": "^20.16.14",
|
||||
"citty": "^0.1.6",
|
||||
"jiti": "^2.2.1",
|
||||
"jiti": "^2.3.3",
|
||||
"pathe": "^1.1.2",
|
||||
"scule": "^1.3.0",
|
||||
"vitepress": "^1.3.4"
|
||||
"vitepress": "^1.4.1"
|
||||
},
|
||||
"scripts": {
|
||||
"all:build": "pnpm -r build",
|
||||
|
||||
9
packages/platform/build.config.ts
Normal file
9
packages/platform/build.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
rollup: {
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/platform",
|
||||
"version": "0.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.2",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
{
|
||||
"name": "@robonen/platform",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"version": "0.0.2",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Platform dependent utilities for javascript development",
|
||||
"keywords": [
|
||||
"javascript",
|
||||
"typescript",
|
||||
"browser",
|
||||
"platform",
|
||||
"node",
|
||||
"bun",
|
||||
"deno"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "./packages/platform"
|
||||
"directory": "packages/platform"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
@@ -19,18 +26,21 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.umd.js",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.umd.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {},
|
||||
"scripts": {
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*"
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"unbuild": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/platform/src/index.ts
Normal file
1
packages/platform/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './multi';
|
||||
@@ -5,7 +5,7 @@
|
||||
* @category Multi
|
||||
* @description Global object that works in any environment
|
||||
*
|
||||
* @since 0.0.2
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export const _global =
|
||||
typeof globalThis !== 'undefined'
|
||||
@@ -17,3 +17,12 @@ export const _global =
|
||||
: typeof self !== 'undefined'
|
||||
? self
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* @name isClient
|
||||
* @category Multi
|
||||
* @description Check if the current environment is the client
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"]
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/renovate"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
@@ -27,6 +27,6 @@
|
||||
"test": "renovate-config-validator ./default.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"renovate": "^38.110.1"
|
||||
"renovate": "^38.130.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/stdlib",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@robonen/stdlib",
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"version": "0.0.2",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A collection of tools, utilities, and helpers for TypeScript",
|
||||
"keywords": [
|
||||
"stdlib",
|
||||
@@ -18,7 +18,7 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/stdlib"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
|
||||
0
packages/stdlib/src/async/index.ts
Normal file
0
packages/stdlib/src/async/index.ts
Normal file
3
packages/stdlib/src/async/pool/index.ts
Normal file
3
packages/stdlib/src/async/pool/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type AsyncPoolOptions = {
|
||||
concurrency?: number;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Create a function that generates unique flags
|
||||
* @name flagsGenerator
|
||||
* @category Bits
|
||||
* @description Create a function that generates unique flags
|
||||
*
|
||||
* @returns {Function} A function that generates unique flags
|
||||
* @throws {RangeError} If more than 31 flags are created
|
||||
@@ -20,7 +22,9 @@ export function flagsGenerator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to combine multiple flags using the AND operator
|
||||
* @name and
|
||||
* @category Bits
|
||||
* @description Function to combine multiple flags using the AND operator
|
||||
*
|
||||
* @param {number[]} flags - The flags to combine
|
||||
* @returns {number} The combined flags
|
||||
@@ -32,7 +36,9 @@ export function and(...flags: number[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to combine multiple flags using the OR operator
|
||||
* @name or
|
||||
* @category Bits
|
||||
* @description Function to combine multiple flags using the OR operator
|
||||
*
|
||||
* @param {number[]} flags - The flags to combine
|
||||
* @returns {number} The combined flags
|
||||
@@ -44,7 +50,9 @@ export function or(...flags: number[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to apply the NOT operator to a flag
|
||||
* @name not
|
||||
* @category Bits
|
||||
* @description Function to combine multiple flags using the XOR operator
|
||||
*
|
||||
* @param {number} flag - The flag to apply the NOT operator to
|
||||
* @returns {number} The result of the NOT operator
|
||||
@@ -56,7 +64,9 @@ export function not(flag: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to make sure a flag has a specific bit set
|
||||
* @name has
|
||||
* @category Bits
|
||||
* @description Function to make sure a flag has a specific bit set
|
||||
*
|
||||
* @param {number} flag - The flag to check
|
||||
* @param {number} other - Flag to check
|
||||
@@ -69,7 +79,9 @@ export function has(flag: number, other: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to check if a flag is set
|
||||
* @name is
|
||||
* @category Bits
|
||||
* @description Function to check if a flag is set
|
||||
*
|
||||
* @param {number} flag - The flag to check
|
||||
* @returns {boolean} Whether the flag is set
|
||||
@@ -81,7 +93,9 @@ export function is(flag: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to unset a flag
|
||||
* @name unset
|
||||
* @category Bits
|
||||
* @description Function to unset a flag
|
||||
*
|
||||
* @param {number} flag - Source flag
|
||||
* @param {number} other - Flag to unset
|
||||
@@ -94,7 +108,9 @@ export function unset(flag: number, other: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to toggle (xor) a flag
|
||||
* @name toggle
|
||||
* @category Bits
|
||||
* @description Function to toggle (xor) a flag
|
||||
*
|
||||
* @param {number} flag - Source flag
|
||||
* @param {number} other - Flag to toggle
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './bits';
|
||||
export * from './structs';
|
||||
export * from './arrays';
|
||||
export * from './types';
|
||||
export * from './utils'
|
||||
|
||||
@@ -43,4 +43,39 @@ describe('clamp', () => {
|
||||
// negative range and value
|
||||
expect(clamp(-10, -100, -5)).toBe(-10);
|
||||
});
|
||||
|
||||
it('handle NaN and Infinity', () => {
|
||||
// value is NaN
|
||||
expect(clamp(NaN, 0, 100)).toBe(NaN);
|
||||
|
||||
// min is NaN
|
||||
expect(clamp(50, NaN, 100)).toBe(NaN);
|
||||
|
||||
// max is NaN
|
||||
expect(clamp(50, 0, NaN)).toBe(NaN);
|
||||
|
||||
// value is Infinity
|
||||
expect(clamp(Infinity, 0, 100)).toBe(100);
|
||||
|
||||
// min is Infinity
|
||||
expect(clamp(50, Infinity, 100)).toBe(100);
|
||||
|
||||
// max is Infinity
|
||||
expect(clamp(50, 0, Infinity)).toBe(50);
|
||||
|
||||
// min and max are Infinity
|
||||
expect(clamp(50, Infinity, Infinity)).toBe(Infinity);
|
||||
|
||||
// value is -Infinity
|
||||
expect(clamp(-Infinity, 0, 100)).toBe(0);
|
||||
|
||||
// min is -Infinity
|
||||
expect(clamp(50, -Infinity, 100)).toBe(50);
|
||||
|
||||
// max is -Infinity
|
||||
expect(clamp(50, 0, -Infinity)).toBe(-Infinity);
|
||||
|
||||
// min and max are -Infinity
|
||||
expect(clamp(50, -Infinity, -Infinity)).toBe(-Infinity);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Clamps a number between a minimum and maximum value
|
||||
* @name clamp
|
||||
* @category Math
|
||||
* @description Clamps a number between a minimum and maximum value
|
||||
*
|
||||
* @param {number} value The number to clamp
|
||||
* @param {number} min Minimum value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {inverseLerp, lerp} from './index';
|
||||
import {inverseLerp, lerp} from '.';
|
||||
|
||||
describe('lerp', () => {
|
||||
it('interpolates between two values', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Linearly interpolates between two values
|
||||
* @name lerp
|
||||
* @category Math
|
||||
* @description Linearly interpolates between two values
|
||||
*
|
||||
* @param {number} start The start value
|
||||
* @param {number} end The end value
|
||||
@@ -13,7 +15,9 @@ export function lerp(start: number, end: number, t: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse linear interpolation between two values
|
||||
* @name inverseLerp
|
||||
* @category Math
|
||||
* @description Inverse linear interpolation between two values
|
||||
*
|
||||
* @param {number} start The start value
|
||||
* @param {number} end The end value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {remap} from './index';
|
||||
import {remap} from '.';
|
||||
|
||||
describe('remap', () => {
|
||||
it('map values from one range to another', () => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { clamp } from '../clamp';
|
||||
import {inverseLerp, lerp} from '../lerp';
|
||||
|
||||
/**
|
||||
* Map a value from one range to another
|
||||
* @name remap
|
||||
* @category Math
|
||||
* @description Map a value from one range to another
|
||||
*
|
||||
* @param {number} value The value to map
|
||||
* @param {number} in_min The minimum value of the input range
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {clampBigInt} from './index';
|
||||
import {clampBigInt} from '.';
|
||||
|
||||
describe('clampBigInt', () => {
|
||||
it('clamp a value within the given range', () => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import {minBigInt} from '../minBigInt';
|
||||
import {maxBigInt} from '../maxBigInt';
|
||||
|
||||
/**
|
||||
* Clamps a bigint between a minimum and maximum value
|
||||
* @name clampBigInt
|
||||
* @category Math
|
||||
* @description Clamps a bigint between a minimum and maximum value
|
||||
*
|
||||
* @param {bigint} value The number to clamp
|
||||
* @param {bigint} min Minimum value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {inverseLerpBigInt, lerpBigInt} from './index';
|
||||
import {inverseLerpBigInt, lerpBigInt} from '.';
|
||||
|
||||
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Linearly interpolates between bigint values
|
||||
* @name lerpBigInt
|
||||
* @category Math
|
||||
* @description Linearly interpolates between bigint values
|
||||
*
|
||||
* @param {bigint} start The start value
|
||||
* @param {bigint} end The end value
|
||||
@@ -13,7 +15,9 @@ export function lerpBigInt(start: bigint, end: bigint, t: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse linear interpolation between two bigint values
|
||||
* @name inverseLerpBigInt
|
||||
* @category Math
|
||||
* @description Inverse linear interpolation between two bigint values
|
||||
*
|
||||
* @param {bigint} start The start value
|
||||
* @param {bigint} end The end value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maxBigInt } from './index';
|
||||
import { maxBigInt } from '.';
|
||||
|
||||
describe('maxBigInt', () => {
|
||||
it('returns -Infinity when no values are provided', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Like `Math.max` but for BigInts
|
||||
* @name maxBigInt
|
||||
* @category Math
|
||||
* @description Like `Math.max` but for BigInts
|
||||
*
|
||||
* @param {...bigint} values The values to compare
|
||||
* @returns {bigint} The largest value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {minBigInt} from './index';
|
||||
import {minBigInt} from '.';
|
||||
|
||||
describe('minBigInt', () => {
|
||||
it('returns Infinity when no values are provided', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Like `Math.min` but for BigInts
|
||||
* @name minBigInt
|
||||
* @category Math
|
||||
* @description Like `Math.min` but for BigInts
|
||||
*
|
||||
* @param {...bigint} values The values to compare
|
||||
* @returns {bigint} The smallest value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {remapBigInt} from './index';
|
||||
import {remapBigInt} from '.';
|
||||
|
||||
describe('remapBigInt', () => {
|
||||
it('map values from one range to another', () => {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { clampBigInt } from '../clampBigInt';
|
||||
import {inverseLerpBigInt, lerpBigInt} from '../lerpBigInt';
|
||||
|
||||
/**
|
||||
* Map a bigint value from one range to another
|
||||
* @name remapBigInt
|
||||
* @category Math
|
||||
* @description Map a bigint value from one range to another
|
||||
*
|
||||
* @param {bigint} value The value to map
|
||||
* @param {bigint} in_min The minimum value of the input range
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { PubSub } from './index';
|
||||
import { PubSub } from '.';
|
||||
|
||||
describe('pubsub', () => {
|
||||
const event3 = Symbol('event3');
|
||||
|
||||
let eventBus: PubSub<{
|
||||
event1: (arg: string) => void;
|
||||
event2: () => void
|
||||
event2: () => void;
|
||||
[event3]: () => void;
|
||||
}>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -32,6 +35,15 @@ describe('pubsub', () => {
|
||||
expect(listener2).toHaveBeenCalledWith('Hello');
|
||||
});
|
||||
|
||||
it('emit symbol event', () => {
|
||||
const listener = vi.fn();
|
||||
|
||||
eventBus.on(event3, listener);
|
||||
eventBus.emit(event3);
|
||||
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('add a one-time listener and emit an event', () => {
|
||||
const listener = vi.fn();
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export type Subscriber = (...args: any[]) => void;
|
||||
export type EventsRecord = Record<string, Subscriber>;
|
||||
export type EventsRecord = Record<string | symbol, Subscriber>;
|
||||
|
||||
/**
|
||||
* Simple PubSub implementation
|
||||
* @name PubSub
|
||||
* @category Patterns
|
||||
* @description Simple PubSub implementation
|
||||
*
|
||||
* @since 0.0.2
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Stack } from './index';
|
||||
import { Stack } from '.';
|
||||
|
||||
describe('stack', () => {
|
||||
describe('constructor', () => {
|
||||
|
||||
@@ -3,7 +3,9 @@ export type StackOptions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a stack data structure
|
||||
* @name Stack
|
||||
* @category Data Structures
|
||||
* @description Represents a stack data structure
|
||||
*
|
||||
* @since 0.0.2
|
||||
*
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Calculate the Levenshtein distance between two strings
|
||||
* @name levenshteinDistance
|
||||
* @category Text
|
||||
* @description Calculate the Levenshtein distance between two strings
|
||||
*
|
||||
* @param {string} left First string
|
||||
* @param {string} right Second string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { templateObject } from './index';
|
||||
import { templateObject } from '.';
|
||||
|
||||
describe('templateObject', () => {
|
||||
// it('replace template placeholders with corresponding values from args', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export type Trigrams = Map<string, number>;
|
||||
|
||||
/**
|
||||
* Extracts trigrams from a text and returns a map of trigram to count
|
||||
* @name trigramProfile
|
||||
* @category Text
|
||||
* @description Extracts trigrams from a text and returns a map of trigram to count
|
||||
*
|
||||
* @param {string} text The text to extract trigrams
|
||||
* @returns {Trigrams} A map of trigram to count
|
||||
@@ -23,7 +25,9 @@ export function trigramProfile(text: string): Trigrams {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the trigram distance between two strings
|
||||
* @name trigramDistance
|
||||
* @category Text
|
||||
* @description Calculates the trigram distance between two strings
|
||||
*
|
||||
* @param {Trigrams} left First text trigram profile
|
||||
* @param {Trigrams} right Second text trigram profile
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* To string any value.
|
||||
* @name toString
|
||||
* @category Types
|
||||
* @description To string any value
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {string}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const toString = (value: any): string => Object.prototype.toString.call(value);
|
||||
@@ -1,81 +1,121 @@
|
||||
import { toString } from '.';
|
||||
|
||||
/**
|
||||
* Check if a value is an array.
|
||||
* @name isFunction
|
||||
* @category Types
|
||||
* @description Check if a value is an array
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is any[]}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isArray = (value: any): value is any[] => Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Check if a value is an object.
|
||||
* @name isObject
|
||||
* @category Types
|
||||
* @description Check if a value is an object
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is object}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isObject = (value: any): value is object => toString(value) === '[object Object]';
|
||||
|
||||
/**
|
||||
* Check if a value is a regexp.
|
||||
* @name isRegExp
|
||||
* @category Types
|
||||
* @description Check if a value is a regexp
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is RegExp}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isRegExp = (value: any): value is RegExp => toString(value) === '[object RegExp]';
|
||||
|
||||
/**
|
||||
* Check if a value is a date.
|
||||
* @name isDate
|
||||
* @category Types
|
||||
* @description Check if a value is a date
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is Date}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isDate = (value: any): value is Date => toString(value) === '[object Date]';
|
||||
|
||||
/**
|
||||
* Check if a value is an error.
|
||||
* @name isError
|
||||
* @category Types
|
||||
* @description Check if a value is an error
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is Error}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isError = (value: any): value is Error => toString(value) === '[object Error]';
|
||||
|
||||
/**
|
||||
* Check if a value is a promise.
|
||||
* @name isPromise
|
||||
* @category Types
|
||||
* @description Check if a value is a promise
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is Promise<any>}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isPromise = (value: any): value is Promise<any> => toString(value) === '[object Promise]';
|
||||
|
||||
/**
|
||||
* Check if a value is a map.
|
||||
* @name isMap
|
||||
* @category Types
|
||||
* @description Check if a value is a map
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is Map<any, any>}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isMap = (value: any): value is Map<any, any> => toString(value) === '[object Map]';
|
||||
|
||||
/**
|
||||
* Check if a value is a set.
|
||||
* @name isSet
|
||||
* @category Types
|
||||
* @description Check if a value is a set
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is Set<any>}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isSet = (value: any): value is Set<any> => toString(value) === '[object Set]';
|
||||
|
||||
/**
|
||||
* Check if a value is a weakmap.
|
||||
* @name isWeakMap
|
||||
* @category Types
|
||||
* @description Check if a value is a weakmap
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is WeakMap<object, any>}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isWeakMap = (value: any): value is WeakMap<object, any> => toString(value) === '[object WeakMap]';
|
||||
|
||||
/**
|
||||
* Check if a value is a weakset.
|
||||
* @name isWeakSet
|
||||
* @category Types
|
||||
* @description Check if a value is a weakset
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is WeakSet<object>}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isWeakSet = (value: any): value is WeakSet<object> => toString(value) === '[object WeakSet]';
|
||||
|
||||
@@ -1,65 +1,97 @@
|
||||
import { toString } from '.';
|
||||
|
||||
/**
|
||||
* Check if a value is a boolean.
|
||||
* @name isObject
|
||||
* @category Types
|
||||
* @description Check if a value is a boolean
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is boolean}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isBoolean = (value: any): value is boolean => typeof value === 'boolean';
|
||||
|
||||
/**
|
||||
* Check if a value is a function.
|
||||
* @name isFunction
|
||||
* @category Types
|
||||
* @description Check if a value is a function
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is Function}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isFunction = <T extends Function>(value: any): value is T => typeof value === 'function';
|
||||
|
||||
/**
|
||||
* Check if a value is a number.
|
||||
* @name isNumber
|
||||
* @category Types
|
||||
* @description Check if a value is a number
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is number}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isNumber = (value: any): value is number => typeof value === 'number';
|
||||
|
||||
/**
|
||||
* Check if a value is a bigint.
|
||||
* @name isBigInt
|
||||
* @category Types
|
||||
* @description Check if a value is a bigint
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is bigint}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isBigInt = (value: any): value is bigint => typeof value === 'bigint';
|
||||
|
||||
/**
|
||||
* Check if a value is a string.
|
||||
* @name isString
|
||||
* @category Types
|
||||
* @description Check if a value is a string
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is string}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isString = (value: any): value is string => typeof value === 'string';
|
||||
|
||||
/**
|
||||
* Check if a value is a symbol.
|
||||
* @name isSymbol
|
||||
* @category Types
|
||||
* @description Check if a value is a symbol
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is symbol}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isSymbol = (value: any): value is symbol => typeof value === 'symbol';
|
||||
|
||||
/**
|
||||
* Check if a value is a undefined.
|
||||
* @name isUndefined
|
||||
* @category Types
|
||||
* @description Check if a value is a undefined
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is undefined}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isUndefined = (value: any): value is undefined => toString(value) === '[object Undefined]';
|
||||
|
||||
/**
|
||||
* Check if a value is a null.
|
||||
* @name isNull
|
||||
* @category Types
|
||||
* @description Check if a value is a null
|
||||
*
|
||||
* @param {any} value
|
||||
* @returns {value is null}
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const isNull = (value: any): value is null => toString(value) === '[object Null]';
|
||||
|
||||
4
packages/stdlib/src/types/ts/array.ts
Normal file
4
packages/stdlib/src/types/ts/array.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* A type that can be either a single value or an array of values
|
||||
*/
|
||||
export type Arrayable<T> = T | T[];
|
||||
9
packages/stdlib/src/types/ts/function.ts
Normal file
9
packages/stdlib/src/types/ts/function.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Any function
|
||||
*/
|
||||
export type AnyFunction = (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* Void function
|
||||
*/
|
||||
export type VoidFunction = () => void;
|
||||
@@ -1 +1,3 @@
|
||||
export * from './string';
|
||||
export * from './string';
|
||||
export * from './function';
|
||||
export * from './array';
|
||||
21
packages/stdlib/src/utils/index.ts
Normal file
21
packages/stdlib/src/utils/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @name timestamp
|
||||
* @category Utils
|
||||
* @description Returns the current timestamp
|
||||
*
|
||||
* @returns {number} The current timestamp
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const timestamp = () => Date.now();
|
||||
|
||||
/**
|
||||
* @name noop
|
||||
* @category Utils
|
||||
* @description A function that does nothing
|
||||
*
|
||||
* @returns {void} Nothing
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export const noop = () => {};
|
||||
@@ -16,7 +16,7 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/tsconfig"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
|
||||
10
packages/vue/build.config.ts
Normal file
10
packages/vue/build.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
externals: ['vue'],
|
||||
rollup: {
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/vue",
|
||||
"version": "0.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.1",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"name": "@robonen/vue",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of powerful tools for Vue",
|
||||
"keywords": [
|
||||
"vue",
|
||||
"tools",
|
||||
"ui",
|
||||
"utilities",
|
||||
"composables"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "./packages/vue"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
@@ -19,27 +24,30 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.umd.js",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.umd.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev"
|
||||
"dev": "vitest dev",
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"unbuild": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
export * from './tryOnBeforeMount';
|
||||
export * from './tryOnMounted';
|
||||
export * from './tryOnScopeDispose';
|
||||
export * from './useAppSharedState';
|
||||
export * from './useCached';
|
||||
export * from './useClamp';
|
||||
export * from './useContextFactory';
|
||||
export * from './useCounter';
|
||||
export * from './useLastChanged';
|
||||
export * from './useMounted';
|
||||
export * from './useOffsetPagination';
|
||||
export * from './useRenderCount';
|
||||
export * from './useRenderInfo';
|
||||
export * from './useSupported';
|
||||
export * from './useSyncRefs';
|
||||
export * from './useToggle';
|
||||
45
packages/vue/src/composables/tryOnBeforeMount/index.ts
Normal file
45
packages/vue/src/composables/tryOnBeforeMount/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
// TODO: test
|
||||
|
||||
export interface TryOnBeforeMountOptions {
|
||||
sync?: boolean;
|
||||
target?: ComponentInternalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name tryOnBeforeMount
|
||||
* @category Components
|
||||
* @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it
|
||||
*
|
||||
* @param {VoidFunction} fn - The function to run on before mount.
|
||||
* @param {TryOnBeforeMountOptions} options - The options for the function.
|
||||
* @param {boolean} [options.sync=true] - If true, the function will run synchronously, otherwise it will run asynchronously.
|
||||
* @param {ComponentInternalInstance} [options.target] - The target component instance to run the function on.
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* tryOnBeforeMount(() => console.log('Before mount'));
|
||||
*
|
||||
* @example
|
||||
* tryOnBeforeMount(() => console.log('Before mount async'), { sync: false });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function tryOnBeforeMount(fn: VoidFunction, options: TryOnBeforeMountOptions = {}) {
|
||||
const {
|
||||
sync = true,
|
||||
target,
|
||||
} = options;
|
||||
|
||||
const instance = getLifeCycleTarger(target);
|
||||
|
||||
if (instance)
|
||||
onBeforeMount(fn, instance);
|
||||
else if (sync)
|
||||
fn();
|
||||
else
|
||||
nextTick(fn);
|
||||
}
|
||||
57
packages/vue/src/composables/tryOnMounted/index.test.ts
Normal file
57
packages/vue/src/composables/tryOnMounted/index.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import { defineComponent, nextTick, type PropType } from 'vue';
|
||||
import { tryOnMounted } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function as PropType<VoidFunction>,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
props.callback && tryOnMounted(props.callback);
|
||||
},
|
||||
template: `<div></div>`,
|
||||
});
|
||||
|
||||
describe('tryOnMounted', () => {
|
||||
it('run the callback when mounted', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run the callback outside of a component lifecycle', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
tryOnMounted(callback);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run the callback asynchronously', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
tryOnMounted(callback, { sync: false });
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
await nextTick();
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('run the callback with a specific target', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
tryOnMounted(callback, { target: component.vm.$ });
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
45
packages/vue/src/composables/tryOnMounted/index.ts
Normal file
45
packages/vue/src/composables/tryOnMounted/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
// TODO: tests
|
||||
|
||||
export interface TryOnMountedOptions {
|
||||
sync?: boolean;
|
||||
target?: ComponentInternalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name tryOnMounted
|
||||
* @category Components
|
||||
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
|
||||
*
|
||||
* @param {VoidFunction} fn The function to call
|
||||
* @param {TryOnMountedOptions} options The options to use
|
||||
* @param {boolean} [options.sync=true] If the function should be called synchronously
|
||||
* @param {ComponentInternalInstance} [options.target] The target instance to use
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* tryOnMounted(() => console.log('Mounted!'));
|
||||
*
|
||||
* @example
|
||||
* tryOnMounted(() => console.log('Mounted!'), { sync: false });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function tryOnMounted(fn: VoidFunction, options: TryOnMountedOptions = {}) {
|
||||
const {
|
||||
sync = true,
|
||||
target,
|
||||
} = options;
|
||||
|
||||
const instance = getLifeCycleTarger(target);
|
||||
|
||||
if (instance)
|
||||
onMounted(fn, instance);
|
||||
else if (sync)
|
||||
fn();
|
||||
else
|
||||
nextTick(fn);
|
||||
}
|
||||
58
packages/vue/src/composables/tryOnScopeDispose/index.test.ts
Normal file
58
packages/vue/src/composables/tryOnScopeDispose/index.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, type PropType } from 'vue';
|
||||
import { tryOnScopeDispose } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function as PropType<VoidFunction>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
tryOnScopeDispose(props.callback);
|
||||
},
|
||||
template: '<div></div>',
|
||||
});
|
||||
|
||||
describe('tryOnScopeDispose', () => {
|
||||
it('returns false when the scope is not active', () => {
|
||||
const callback = vi.fn();
|
||||
const detectedScope = tryOnScopeDispose(callback);
|
||||
|
||||
expect(detectedScope).toBe(false);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run the callback when the scope is disposed', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let detectedScope: boolean | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
detectedScope = tryOnScopeDispose(callback);
|
||||
});
|
||||
|
||||
expect(detectedScope).toBe(true);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('run callback when the component is unmounted', () => {
|
||||
const callback = vi.fn();
|
||||
const component = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
component.unmount();
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
24
packages/vue/src/composables/tryOnScopeDispose/index.ts
Normal file
24
packages/vue/src/composables/tryOnScopeDispose/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { getCurrentScope, onScopeDispose } from 'vue';
|
||||
|
||||
/**
|
||||
* @name tryOnScopeDispose
|
||||
* @category Components
|
||||
* @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available.
|
||||
*
|
||||
* @param {VoidFunction} callback - The callback to run when the scope is disposed.
|
||||
* @returns {boolean} - Returns true if the callback was run, otherwise false.
|
||||
*
|
||||
* @example
|
||||
* tryOnScopeDispose(() => console.log('Scope disposed'));
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function tryOnScopeDispose(callback: VoidFunction) {
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(callback);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
40
packages/vue/src/composables/useAppSharedState/index.test.ts
Normal file
40
packages/vue/src/composables/useAppSharedState/index.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useAppSharedState } from '.';
|
||||
|
||||
describe('useAppSharedState', () => {
|
||||
it('initialize state only once', () => {
|
||||
const stateFactory = (initValue?: number) => {
|
||||
const count = ref(initValue ?? 0);
|
||||
return { count };
|
||||
};
|
||||
|
||||
const useSharedState = useAppSharedState(stateFactory);
|
||||
|
||||
const state1 = useSharedState(1);
|
||||
const state2 = useSharedState(2);
|
||||
|
||||
expect(state1.count.value).toBe(1);
|
||||
expect(state2.count.value).toBe(1);
|
||||
expect(state1).toBe(state2);
|
||||
});
|
||||
|
||||
it('return the same state object across different calls', () => {
|
||||
const stateFactory = () => {
|
||||
const state = reactive({ count: 0 });
|
||||
const increment = () => state.count++;
|
||||
return { state, increment };
|
||||
};
|
||||
|
||||
const useSharedState = useAppSharedState(stateFactory);
|
||||
|
||||
const sharedState1 = useSharedState();
|
||||
const sharedState2 = useSharedState();
|
||||
|
||||
expect(sharedState1.state.count).toBe(0);
|
||||
sharedState1.increment();
|
||||
expect(sharedState1.state.count).toBe(1);
|
||||
expect(sharedState2.state.count).toBe(1);
|
||||
expect(sharedState1).toBe(sharedState2);
|
||||
});
|
||||
});
|
||||
42
packages/vue/src/composables/useAppSharedState/index.ts
Normal file
42
packages/vue/src/composables/useAppSharedState/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AnyFunction } from '@robonen/stdlib';
|
||||
import { effectScope } from 'vue';
|
||||
|
||||
// TODO: maybe we should control subscriptions and dispose them when the child scope is disposed
|
||||
|
||||
/**
|
||||
* @name useAppSharedState
|
||||
* @category State
|
||||
* @description Provides a shared state object for use across Vue instances
|
||||
*
|
||||
* @param {Function} stateFactory A factory function that returns the shared state object
|
||||
* @returns {Function} A function that returns the shared state object
|
||||
*
|
||||
* @example
|
||||
* const useSharedState = useAppSharedState((initValue?: number) => {
|
||||
* const count = ref(initValue ?? 0);
|
||||
* return { count };
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* const useSharedState = useAppSharedState(() => {
|
||||
* const state = reactive({ count: 0 });
|
||||
* const increment = () => state.count++;
|
||||
* return { state, increment };
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useAppSharedState<Fn extends AnyFunction>(stateFactory: Fn) {
|
||||
let initialized = false;
|
||||
let state: ReturnType<Fn>;
|
||||
const scope = effectScope(true);
|
||||
|
||||
return ((...args: Parameters<Fn>) => {
|
||||
if (!initialized) {
|
||||
state = scope.run(() => stateFactory(...args));
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { ref, nextTick, reactive } from 'vue';
|
||||
import { useCached } from '.';
|
||||
|
||||
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
@@ -14,10 +14,6 @@ describe('useCached', () => {
|
||||
externalValue.value = 1;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(1);
|
||||
|
||||
externalValue.value = 10;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(10);
|
||||
});
|
||||
|
||||
it('custom array comparator', async () => {
|
||||
@@ -41,4 +37,15 @@ describe('useCached', () => {
|
||||
expect(cachedValue.value).not.toEqual(initialValue);
|
||||
expect(cachedValue.value).toEqual([2]);
|
||||
});
|
||||
|
||||
it('getter source', async () => {
|
||||
const externalValue = reactive({ value: 0 });
|
||||
const cachedValue = useCached(() => externalValue.value);
|
||||
|
||||
expect(cachedValue.value).toBe(0);
|
||||
|
||||
externalValue.value = 1;
|
||||
await nextTick();
|
||||
expect(cachedValue.value).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, watch, type Ref, type WatchOptions } from 'vue';
|
||||
import { ref, watch, toValue, type MaybeRefOrGetter, type Ref, type WatchOptions } from 'vue';
|
||||
|
||||
export type Comparator<Value> = (a: Value, b: Value) => boolean;
|
||||
|
||||
@@ -19,15 +19,17 @@ export type Comparator<Value> = (a: Value, b: Value) => boolean;
|
||||
* @example
|
||||
* const externalValue = ref(0);
|
||||
* const cachedValue = useCached(externalValue, (a, b) => a === b, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useCached<Value = unknown>(
|
||||
externalValue: Ref<Value>,
|
||||
externalValue: MaybeRefOrGetter<Value>,
|
||||
comparator: Comparator<Value> = (a, b) => a === b,
|
||||
watchOptions?: WatchOptions,
|
||||
): Ref<Value> {
|
||||
const cached = ref(externalValue.value) as Ref<Value>;
|
||||
const cached = ref(toValue(externalValue)) as Ref<Value>;
|
||||
|
||||
watch(() => externalValue.value, (value) => {
|
||||
watch(() => toValue(externalValue), (value) => {
|
||||
if (!comparator(value, cached.value))
|
||||
cached.value = value;
|
||||
}, watchOptions);
|
||||
|
||||
60
packages/vue/src/composables/useClamp/index.test.ts
Normal file
60
packages/vue/src/composables/useClamp/index.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ref, readonly, computed } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useClamp } from '.';
|
||||
|
||||
describe('useClamp', () => {
|
||||
it('non-reactive values should be clamped', () => {
|
||||
const clampedValue = useClamp(10, 0, 5);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamp the value within the given range', () => {
|
||||
const value = ref(10);
|
||||
const clampedValue = useClamp(value, 0, 5);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamp the value within the given range using functions', () => {
|
||||
const value = ref(10);
|
||||
const clampedValue = useClamp(value, () => 0, () => 5);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
});
|
||||
|
||||
it('clamp readonly values', () => {
|
||||
const computedValue = computed(() => 10);
|
||||
const readonlyValue = readonly(ref(10));
|
||||
const clampedValue1 = useClamp(computedValue, 0, 5);
|
||||
const clampedValue2 = useClamp(readonlyValue, 0, 5);
|
||||
|
||||
expect(clampedValue1.value).toBe(5);
|
||||
expect(clampedValue2.value).toBe(5);
|
||||
});
|
||||
|
||||
it('update the clamped value when the original value changes', () => {
|
||||
const value = ref(10);
|
||||
const clampedValue = useClamp(value, 0, 5);
|
||||
value.value = 3;
|
||||
|
||||
expect(clampedValue.value).toBe(3);
|
||||
});
|
||||
|
||||
it('update the clamped value when the min or max changes', () => {
|
||||
const value = ref(10);
|
||||
const min = ref(0);
|
||||
const max = ref(5);
|
||||
const clampedValue = useClamp(value, min, max);
|
||||
|
||||
expect(clampedValue.value).toBe(5);
|
||||
|
||||
max.value = 15;
|
||||
|
||||
expect(clampedValue.value).toBe(10);
|
||||
|
||||
min.value = 11;
|
||||
|
||||
expect(clampedValue.value).toBe(11);
|
||||
});
|
||||
});
|
||||
39
packages/vue/src/composables/useClamp/index.ts
Normal file
39
packages/vue/src/composables/useClamp/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { clamp, isFunction } from '@robonen/stdlib';
|
||||
import { computed, isReadonly, ref, toValue, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type WritableComputedRef } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useClamp
|
||||
* @category Math
|
||||
* @description Clamps a value between a minimum and maximum value
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number>} value The value to clamp
|
||||
* @param {MaybeRefOrGetter<number>} min The minimum value
|
||||
* @param {MaybeRefOrGetter<number>} max The maximum value
|
||||
* @returns {ComputedRef<number>} The clamped value
|
||||
*
|
||||
* @example
|
||||
* const value = ref(10);
|
||||
* const clampedValue = useClamp(value, 0, 5);
|
||||
*
|
||||
* @example
|
||||
* const value = ref(10);
|
||||
* const clampedValue = useClamp(value, () => 0, () => 5);
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useClamp(value: MaybeRef<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): WritableComputedRef<number>;
|
||||
export function useClamp(value: MaybeRefOrGetter<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): ComputedRef<number> {
|
||||
if (isFunction(value) || isReadonly(value))
|
||||
return computed(() => clamp(toValue(value), toValue(min), toValue(max)));
|
||||
|
||||
const _value = ref(value);
|
||||
|
||||
return computed<number>({
|
||||
get() {
|
||||
return clamp(_value.value, toValue(min), toValue(max));
|
||||
},
|
||||
set(newValue) {
|
||||
_value.value = clamp(newValue, toValue(min), toValue(max));
|
||||
},
|
||||
});
|
||||
}
|
||||
69
packages/vue/src/composables/useContextFactory/index.test.ts
Normal file
69
packages/vue/src/composables/useContextFactory/index.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { useContextFactory } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { VueToolsError } from '../../utils';
|
||||
|
||||
function testFactory<Data>(
|
||||
data: Data,
|
||||
options?: { contextName?: string, fallback?: Data },
|
||||
) {
|
||||
const contextName = options?.contextName ?? 'TestContext';
|
||||
|
||||
const [inject, provide] = useContextFactory(contextName);
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const value = inject(options?.fallback);
|
||||
return { value };
|
||||
},
|
||||
template: `{{ value }}`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
provide(data);
|
||||
},
|
||||
template: `<Child />`,
|
||||
});
|
||||
|
||||
return {
|
||||
Parent,
|
||||
Child,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: maybe replace template with passing mock functions to setup
|
||||
|
||||
describe('useContextFactory', () => {
|
||||
it('provide and inject context correctly', () => {
|
||||
const { Parent } = testFactory('test');
|
||||
|
||||
const component = mount(Parent);
|
||||
|
||||
expect(component.text()).toBe('test');
|
||||
});
|
||||
|
||||
it('throw an error when context is not provided', () => {
|
||||
const { Child } = testFactory('test');
|
||||
|
||||
expect(() => mount(Child)).toThrow(VueToolsError);
|
||||
});
|
||||
|
||||
it('inject a fallback value when context is not provided', () => {
|
||||
const { Child } = testFactory('test', { fallback: 'fallback' });
|
||||
|
||||
const component = mount(Child);
|
||||
|
||||
expect(component.text()).toBe('fallback');
|
||||
});
|
||||
|
||||
it('correctly handle null values', () => {
|
||||
const { Parent } = testFactory(null);
|
||||
|
||||
const component = mount(Parent);
|
||||
|
||||
expect(component.text()).toBe('');
|
||||
});
|
||||
});
|
||||
36
packages/vue/src/composables/useContextFactory/index.ts
Normal file
36
packages/vue/src/composables/useContextFactory/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { inject, provide, type InjectionKey } from 'vue';
|
||||
import { VueToolsError } from '../..';
|
||||
|
||||
/**
|
||||
* @name useContextFactory
|
||||
* @category Utilities
|
||||
* @description A composable that provides a factory for creating context with unique key
|
||||
*
|
||||
* @param {string} name The name of the context
|
||||
* @returns {readonly [injectContext, provideContext]} The context factory
|
||||
* @throws {VueToolsError} when the context is not provided
|
||||
*
|
||||
* @example
|
||||
* const [injectContext, provideContext] = useContextFactory('MyContext');
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useContextFactory<ContextValue>(name: string) {
|
||||
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
|
||||
|
||||
const injectContext = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
|
||||
const context = inject(injectionKey, fallback);
|
||||
|
||||
if (context !== undefined)
|
||||
return context;
|
||||
|
||||
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
|
||||
};
|
||||
|
||||
const provideContext = (context: ContextValue) => {
|
||||
provide(injectionKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
return [injectContext, provideContext] as const;
|
||||
}
|
||||
@@ -13,6 +13,11 @@ describe('useCounter', () => {
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('initialize count with the provided initial value from a getter', () => {
|
||||
const { count } = useCounter(() => 5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('increment count by 1 by default', () => {
|
||||
const { count, increment } = useCounter(0);
|
||||
increment();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, unref, type MaybeRef, type Ref } from 'vue';
|
||||
import { ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
|
||||
export interface UseCounterOptions {
|
||||
@@ -31,13 +31,15 @@ export interface UseConterReturn {
|
||||
*
|
||||
* @example
|
||||
* const { count, increment, decrement, set, get, reset } = useCounter(0, { min: 0, max: 10 });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useCounter(
|
||||
initialValue: MaybeRef<number> = 0,
|
||||
initialValue: MaybeRefOrGetter<number> = 0,
|
||||
options: UseCounterOptions = {},
|
||||
): UseConterReturn {
|
||||
let _initialValue = unref(initialValue);
|
||||
const count = ref(initialValue);
|
||||
let _initialValue = toValue(initialValue);
|
||||
const count = ref(_initialValue);
|
||||
|
||||
const {
|
||||
min = Number.MIN_SAFE_INTEGER,
|
||||
|
||||
136
packages/vue/src/composables/useEventListener/index.ts
Normal file
136
packages/vue/src/composables/useEventListener/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { defaultWindow } from '../..';
|
||||
|
||||
// TODO: wip
|
||||
|
||||
interface InferEventTarget<Events> {
|
||||
addEventListener: (event: Events, listener?: any, options?: any) => any;
|
||||
removeEventListener: (event: Events, listener?: any, options?: any) => any;
|
||||
}
|
||||
|
||||
export interface GeneralEventListener<E = Event> {
|
||||
(evt: E): void;
|
||||
}
|
||||
|
||||
export type WindowEventName = keyof WindowEventMap;
|
||||
export type DocumentEventName = keyof DocumentEventMap;
|
||||
export type ElementEventName = keyof HTMLElementEventMap;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Elements
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 1: Omitted window target
|
||||
*/
|
||||
export function useEventListener<E extends WindowEventName>(
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Elements
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 2: Explicit window target
|
||||
*/
|
||||
export function useEventListener<E extends WindowEventName>(
|
||||
target: Window,
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Elements
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 3: Explicit document target
|
||||
*/
|
||||
export function useEventListener<E extends DocumentEventName>(
|
||||
target: Document,
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Elements
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 4: Explicit HTMLElement target
|
||||
*/
|
||||
export function useEventListener<E extends ElementEventName>(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
event: Arrayable<E>,
|
||||
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
|
||||
): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Elements
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 5: Custom target with inferred event type
|
||||
*/
|
||||
export function useEventListener<Names extends string, EventType = Event>(
|
||||
target: MaybeRefOrGetter<InferEventTarget<Names> | null | undefined>,
|
||||
event: Arrayable<Names>,
|
||||
listener: Arrayable<GeneralEventListener<EventType>>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
|
||||
)
|
||||
|
||||
/**
|
||||
* @name useEventListener
|
||||
* @category Elements
|
||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||
*
|
||||
* Overload 6: Custom event target fallback
|
||||
*/
|
||||
export function useEventListener<EventType = Event>(
|
||||
target: MaybeRefOrGetter<EventTarget | null | undefined>,
|
||||
event: Arrayable<string>,
|
||||
listener: Arrayable<GeneralEventListener<EventType>>,
|
||||
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
|
||||
): VoidFunction;
|
||||
|
||||
export function useEventListener(...args: any[]) {
|
||||
let target: MaybeRefOrGetter<EventTarget> | undefined;
|
||||
let events: Arrayable<string>;
|
||||
let listeners: Arrayable<Function>;
|
||||
let options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
|
||||
|
||||
if (isString(args[0]) || isArray(args[0])) {
|
||||
[events, listeners, options] = args;
|
||||
target = defaultWindow;
|
||||
} else {
|
||||
[target, events, listeners, options] = args;
|
||||
}
|
||||
|
||||
if (!target)
|
||||
return noop;
|
||||
|
||||
if (!isArray(events))
|
||||
events = [events];
|
||||
|
||||
if (!isArray(listeners))
|
||||
listeners = [listeners];
|
||||
|
||||
const cleanups: Function[] = [];
|
||||
|
||||
const cleanup = () => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups.length = 0;
|
||||
}
|
||||
|
||||
const register = (el: any, event: string, listener: any, options: any) => {
|
||||
el.addEventListener(event, listener, options);
|
||||
return () => el.removeEventListener(event, listener, options);
|
||||
}
|
||||
}
|
||||
50
packages/vue/src/composables/useLastChanged/index.test.ts
Normal file
50
packages/vue/src/composables/useLastChanged/index.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useLastChanged } from '.';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
|
||||
describe('useLastChanged', () => {
|
||||
it('initialize with null if no initialValue is provided', () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
expect(lastChanged.value).toBeNull();
|
||||
});
|
||||
|
||||
it('initialize with the provided initialValue', () => {
|
||||
const source = ref(0);
|
||||
const initialValue = 123456789;
|
||||
const lastChanged = useLastChanged(source, { initialValue });
|
||||
|
||||
expect(lastChanged.value).toBe(initialValue);
|
||||
});
|
||||
|
||||
it('update the timestamp when the source changes', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
const initialTimestamp = lastChanged.value;
|
||||
source.value = 1;
|
||||
await nextTick();
|
||||
|
||||
expect(lastChanged.value).not.toBe(initialTimestamp);
|
||||
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
|
||||
});
|
||||
|
||||
it('update the timestamp immediately if immediate option is true', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source, { immediate: true });
|
||||
|
||||
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
|
||||
});
|
||||
|
||||
it('not update the timestamp if the source does not change', async () => {
|
||||
const source = ref(0);
|
||||
const lastChanged = useLastChanged(source);
|
||||
|
||||
const initialTimestamp = lastChanged.value;
|
||||
await nextTick();
|
||||
|
||||
expect(lastChanged.value).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
38
packages/vue/src/composables/useLastChanged/index.ts
Normal file
38
packages/vue/src/composables/useLastChanged/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { ref, watch, type WatchSource, type WatchOptions, type Ref } from 'vue';
|
||||
|
||||
export interface UseLastChangedOptions<
|
||||
Immediate extends boolean,
|
||||
InitialValue extends number | null | undefined = undefined,
|
||||
> extends WatchOptions<Immediate> {
|
||||
initialValue?: InitialValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useLastChanged
|
||||
* @category State
|
||||
* @description Records the last time a value changed
|
||||
*
|
||||
* @param {WatchSource} source The value to track
|
||||
* @param {UseLastChangedOptions} [options={}] The options for the last changed tracker
|
||||
* @returns {Ref<number | null>} The timestamp of the last change
|
||||
*
|
||||
* @example
|
||||
* const value = ref(0);
|
||||
* const lastChanged = useLastChanged(value);
|
||||
*
|
||||
* @example
|
||||
* const value = ref(0);
|
||||
* const lastChanged = useLastChanged(value, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useLastChanged(source: WatchSource, options?: UseLastChangedOptions<false>): Ref<number | null>;
|
||||
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<true> | UseLastChangedOptions<boolean, number>): Ref<number>
|
||||
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<boolean, any> = {}): Ref<number | null> | Ref<number> {
|
||||
const lastChanged = ref<number | null>(options.initialValue ?? null);
|
||||
|
||||
watch(source, () => lastChanged.value = timestamp(), options);
|
||||
|
||||
return lastChanged;
|
||||
}
|
||||
27
packages/vue/src/composables/useMounted/index.test.ts
Normal file
27
packages/vue/src/composables/useMounted/index.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useMounted } from '.';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
setup() {
|
||||
const isMounted = useMounted();
|
||||
|
||||
return { isMounted };
|
||||
},
|
||||
template: `<div>{{ isMounted }}</div>`,
|
||||
});
|
||||
|
||||
describe('useMounted', () => {
|
||||
it('return the mounted state of the component', async () => {
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
// Initial render
|
||||
expect(component.text()).toBe('false');
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a render
|
||||
expect(component.text()).toBe('true');
|
||||
});
|
||||
});
|
||||
27
packages/vue/src/composables/useMounted/index.ts
Normal file
27
packages/vue/src/composables/useMounted/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
|
||||
/**
|
||||
* @name useMounted
|
||||
* @category Components
|
||||
* @description Returns a ref that tracks the mounted state of the component (doesn't track the unmounted state)
|
||||
*
|
||||
* @param {ComponentInternalInstance} [instance] The component instance to track the mounted state for
|
||||
* @returns {Readonly<Ref<boolean>>} The mounted state of the component
|
||||
*
|
||||
* @example
|
||||
* const isMounted = useMounted();
|
||||
*
|
||||
* @example
|
||||
* const isMounted = useMounted(getCurrentInstance());
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useMounted(instance?: ComponentInternalInstance) {
|
||||
const isMounted = ref(false);
|
||||
const targetInstance = getLifeCycleTarger(instance);
|
||||
|
||||
onMounted(() => isMounted.value = true, targetInstance);
|
||||
|
||||
return readonly(isMounted);
|
||||
}
|
||||
147
packages/vue/src/composables/useOffsetPagination/index.test.ts
Normal file
147
packages/vue/src/composables/useOffsetPagination/index.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useOffsetPagination } from '.';
|
||||
|
||||
describe('useOffsetPagination', () => {
|
||||
it('initialize with default values without options', () => {
|
||||
const { currentPage, currentPageSize, totalPages, isFirstPage } = useOffsetPagination({});
|
||||
|
||||
expect(currentPage.value).toBe(1);
|
||||
expect(currentPageSize.value).toBe(10);
|
||||
expect(totalPages.value).toBe(Infinity);
|
||||
expect(isFirstPage.value).toBe(true);
|
||||
});
|
||||
|
||||
it('calculate total pages correctly', () => {
|
||||
const { totalPages } = useOffsetPagination({ total: 100, pageSize: 10 });
|
||||
|
||||
expect(totalPages.value).toBe(10);
|
||||
});
|
||||
|
||||
it('update current page correctly', () => {
|
||||
const { currentPage, next, previous, select } = useOffsetPagination({ total: 100, pageSize: 10 });
|
||||
|
||||
next();
|
||||
expect(currentPage.value).toBe(2);
|
||||
|
||||
previous();
|
||||
expect(currentPage.value).toBe(1);
|
||||
|
||||
select(5);
|
||||
expect(currentPage.value).toBe(5);
|
||||
});
|
||||
|
||||
it('handle out of bounds increments correctly', () => {
|
||||
const { currentPage, next, previous } = useOffsetPagination({ total: 10, pageSize: 5 });
|
||||
|
||||
next();
|
||||
next();
|
||||
next();
|
||||
|
||||
expect(currentPage.value).toBe(2);
|
||||
|
||||
previous();
|
||||
previous();
|
||||
previous();
|
||||
|
||||
expect(currentPage.value).toBe(1);
|
||||
});
|
||||
|
||||
it('handle page boundaries correctly', () => {
|
||||
const { currentPage, isFirstPage, isLastPage } = useOffsetPagination({ total: 20, pageSize: 10 });
|
||||
|
||||
expect(currentPage.value).toBe(1);
|
||||
expect(isFirstPage.value).toBe(true);
|
||||
expect(isLastPage.value).toBe(false);
|
||||
|
||||
currentPage.value = 2;
|
||||
|
||||
expect(currentPage.value).toBe(2);
|
||||
expect(isFirstPage.value).toBe(false);
|
||||
expect(isLastPage.value).toBe(true);
|
||||
});
|
||||
|
||||
it('call onPageChange callback', async () => {
|
||||
const onPageChange = vi.fn();
|
||||
const { currentPage, next } = useOffsetPagination({ total: 100, pageSize: 10, onPageChange });
|
||||
|
||||
next();
|
||||
await nextTick();
|
||||
|
||||
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageChange).toHaveBeenCalledWith(expect.objectContaining({ currentPage: currentPage.value }));
|
||||
});
|
||||
|
||||
it('call onPageSizeChange callback', async () => {
|
||||
const onPageSizeChange = vi.fn();
|
||||
const pageSize = ref(10);
|
||||
const { currentPageSize } = useOffsetPagination({ total: 100, pageSize, onPageSizeChange });
|
||||
|
||||
pageSize.value = 20;
|
||||
await nextTick();
|
||||
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(expect.objectContaining({ currentPageSize: currentPageSize.value }));
|
||||
});
|
||||
|
||||
it('call onPageCountChange callback', async () => {
|
||||
const onTotalPagesChange = vi.fn();
|
||||
const total = ref(100);
|
||||
const { totalPages } = useOffsetPagination({ total, pageSize: 10, onTotalPagesChange });
|
||||
|
||||
total.value = 200;
|
||||
await nextTick();
|
||||
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledWith(expect.objectContaining({ totalPages: totalPages.value }));
|
||||
});
|
||||
|
||||
it('handle complex reactive options', async () => {
|
||||
const total = ref(100);
|
||||
const pageSize = ref(10);
|
||||
const page = ref(1);
|
||||
|
||||
const onPageChange = vi.fn();
|
||||
const onPageSizeChange = vi.fn();
|
||||
const onTotalPagesChange = vi.fn();
|
||||
|
||||
const { currentPage, currentPageSize, totalPages } = useOffsetPagination({
|
||||
total,
|
||||
pageSize,
|
||||
page,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onTotalPagesChange,
|
||||
});
|
||||
|
||||
// Initial values
|
||||
expect(currentPage.value).toBe(1);
|
||||
expect(currentPageSize.value).toBe(10);
|
||||
expect(totalPages.value).toBe(10);
|
||||
expect(onPageChange).toHaveBeenCalledTimes(0);
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(0);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(0);
|
||||
|
||||
total.value = 300;
|
||||
pageSize.value = 15;
|
||||
page.value = 2;
|
||||
await nextTick();
|
||||
|
||||
// Valid values after changes
|
||||
expect(currentPage.value).toBe(2);
|
||||
expect(currentPageSize.value).toBe(15);
|
||||
expect(totalPages.value).toBe(20);
|
||||
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
page.value = 21;
|
||||
await nextTick();
|
||||
|
||||
// Invalid values after changes
|
||||
expect(currentPage.value).toBe(20);
|
||||
expect(onPageChange).toHaveBeenCalledTimes(2);
|
||||
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
126
packages/vue/src/composables/useOffsetPagination/index.ts
Normal file
126
packages/vue/src/composables/useOffsetPagination/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue';
|
||||
import { useClamp } from '../useClamp';
|
||||
|
||||
// TODO: sync returned refs with passed refs
|
||||
|
||||
export interface UseOffsetPaginationOptions {
|
||||
total?: MaybeRefOrGetter<number>;
|
||||
pageSize?: MaybeRef<number>;
|
||||
page?: MaybeRef<number>;
|
||||
onPageChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||
onPageSizeChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||
onTotalPagesChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||
}
|
||||
|
||||
export interface UseOffsetPaginationReturn {
|
||||
currentPage: WritableComputedRef<number>;
|
||||
currentPageSize: WritableComputedRef<number>;
|
||||
totalPages: ComputedRef<number>;
|
||||
isFirstPage: ComputedRef<boolean>;
|
||||
isLastPage: ComputedRef<boolean>;
|
||||
next: VoidFunction;
|
||||
previous: VoidFunction;
|
||||
select: (page: number) => void;
|
||||
}
|
||||
|
||||
export type UseOffsetPaginationInfinityReturn = Omit<UseOffsetPaginationReturn, 'isLastPage'>;
|
||||
|
||||
/**
|
||||
* @name useOffsetPagination
|
||||
* @category Utilities
|
||||
* @description A composable function that provides pagination functionality for offset based pagination
|
||||
*
|
||||
* @param {UseOffsetPaginationOptions} options The options for the pagination
|
||||
* @param {MaybeRefOrGetter<number>} options.total The total number of items
|
||||
* @param {MaybeRef<number>} options.pageSize The number of items per page
|
||||
* @param {MaybeRef<number>} options.page The current page
|
||||
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageChange A callback that is called when the page changes
|
||||
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageSizeChange A callback that is called when the page size changes
|
||||
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onTotalPagesChange A callback that is called when the total number of pages changes
|
||||
* @returns {UseOffsetPaginationReturn} The pagination object
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* currentPage,
|
||||
* currentPageSize,
|
||||
* totalPages,
|
||||
* isFirstPage,
|
||||
* isLastPage,
|
||||
* next,
|
||||
* previous,
|
||||
* select,
|
||||
* } = useOffsetPagination({ total: 100, pageSize: 10, page: 1 });
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* currentPage,
|
||||
* } = useOffsetPagination({
|
||||
* total: 100,
|
||||
* pageSize: 10,
|
||||
* page: 1,
|
||||
* onPageChange: ({ currentPage }) => console.log(currentPage),
|
||||
* onPageSizeChange: ({ currentPageSize }) => console.log(currentPageSize),
|
||||
* onTotalPagesChange: ({ totalPages }) => console.log(totalPages),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useOffsetPagination(options: Omit<UseOffsetPaginationOptions, 'total'>): UseOffsetPaginationInfinityReturn;
|
||||
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn;
|
||||
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn {
|
||||
const {
|
||||
total = Number.POSITIVE_INFINITY,
|
||||
pageSize = 10,
|
||||
page = 1,
|
||||
} = options;
|
||||
|
||||
const currentPageSize = useClamp(pageSize, 1, Number.POSITIVE_INFINITY);
|
||||
|
||||
const totalPages = computed(() => Math.max(
|
||||
1,
|
||||
Math.ceil(toValue(total) / toValue(currentPageSize))
|
||||
));
|
||||
|
||||
const currentPage = useClamp(page, 1, totalPages);
|
||||
|
||||
const isFirstPage = computed(() => currentPage.value === 1);
|
||||
const isLastPage = computed(() => currentPage.value === totalPages.value);
|
||||
|
||||
const next = () => currentPage.value++;
|
||||
const previous = () => currentPage.value--;
|
||||
const select = (page: number) => currentPage.value = page;
|
||||
|
||||
const returnValue = {
|
||||
currentPage,
|
||||
currentPageSize,
|
||||
totalPages,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
next,
|
||||
previous,
|
||||
select,
|
||||
};
|
||||
|
||||
// NOTE: Don't forget to await nextTick() after calling next() or previous() to ensure the callback is called
|
||||
|
||||
if (options.onPageChange) {
|
||||
watch(currentPage, () => {
|
||||
options.onPageChange!(reactive(returnValue));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onPageSizeChange) {
|
||||
watch(currentPageSize, () => {
|
||||
options.onPageSizeChange!(reactive(returnValue));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onTotalPagesChange) {
|
||||
watch(totalPages, () => {
|
||||
options.onTotalPagesChange!(reactive(returnValue));
|
||||
});
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
@@ -19,20 +19,20 @@ describe('useRenderCount', () => {
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
// Initial render
|
||||
expect(component.vm.count).toBe(0);
|
||||
expect(component.vm.count).toBe(1);
|
||||
|
||||
component.vm.hiddenCount = 1;
|
||||
await nextTick();
|
||||
|
||||
// Will not trigger a render
|
||||
expect(component.vm.count).toBe(0);
|
||||
expect(component.vm.count).toBe(1);
|
||||
expect(component.text()).toBe('0');
|
||||
|
||||
component.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a render
|
||||
expect(component.vm.count).toBe(1);
|
||||
expect(component.vm.count).toBe(2);
|
||||
expect(component.text()).toBe('1');
|
||||
|
||||
component.vm.visibleCount++;
|
||||
@@ -40,7 +40,7 @@ describe('useRenderCount', () => {
|
||||
await nextTick();
|
||||
|
||||
// Will trigger a single render for both updates
|
||||
expect(component.vm.count).toBe(2);
|
||||
expect(component.vm.count).toBe(3);
|
||||
expect(component.text()).toBe('3');
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('useRenderCount', () => {
|
||||
|
||||
const count = useRenderCount(instance);
|
||||
|
||||
// Initial render
|
||||
// Initial render (should be zero because the component has already rendered on mount)
|
||||
expect(count.value).toBe(0);
|
||||
|
||||
component.vm.hiddenCount = 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { onUpdated, readonly, type ComponentInternalInstance } from 'vue';
|
||||
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
|
||||
import { useCounter } from '../useCounter';
|
||||
import { getLifeCycleTarger } from '../../utils';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
|
||||
/**
|
||||
* @name useRenderCount
|
||||
@@ -15,11 +15,15 @@ import { getLifeCycleTarger } from '../../utils';
|
||||
*
|
||||
* @example
|
||||
* const count = useRenderCount(getCurrentInstance());
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useRenderCount(instance?: ComponentInternalInstance) {
|
||||
const { count, increment } = useCounter(0);
|
||||
const target = getLifeCycleTarger(instance);
|
||||
|
||||
onUpdated(increment, getLifeCycleTarger(instance));
|
||||
onMounted(increment, target);
|
||||
onUpdated(increment, target);
|
||||
|
||||
return readonly(count);
|
||||
}
|
||||
100
packages/vue/src/composables/useRenderInfo/index.test.ts
Normal file
100
packages/vue/src/composables/useRenderInfo/index.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useRenderInfo } from '.';
|
||||
import { defineComponent, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const NamedComponentStub = defineComponent({
|
||||
name: 'ComponentStub',
|
||||
setup() {
|
||||
const info = useRenderInfo();
|
||||
const visibleCount = ref(0);
|
||||
const hiddenCount = ref(0);
|
||||
|
||||
return { info, visibleCount, hiddenCount };
|
||||
},
|
||||
template: `<div>{{ visibleCount }}</div>`,
|
||||
});
|
||||
|
||||
const UnnamedComponentStub = defineComponent({
|
||||
setup() {
|
||||
const info = useRenderInfo();
|
||||
const visibleCount = ref(0);
|
||||
const hiddenCount = ref(0);
|
||||
|
||||
return { info, visibleCount, hiddenCount };
|
||||
},
|
||||
template: `<div>{{ visibleCount }}</div>`,
|
||||
});
|
||||
|
||||
describe('useRenderInfo', () => {
|
||||
it('return uid if component name is not available', async () => {
|
||||
const wrapper = mount(UnnamedComponentStub);
|
||||
|
||||
expect(wrapper.vm.info.component).toBe(wrapper.vm.$.uid);
|
||||
});
|
||||
|
||||
it('return render info for the given instance', async () => {
|
||||
const wrapper = mount(NamedComponentStub);
|
||||
|
||||
// Initial render
|
||||
expect(wrapper.vm.info.component).toBe('ComponentStub');
|
||||
expect(wrapper.vm.info.count.value).toBe(1);
|
||||
expect(wrapper.vm.info.duration.value).toBeGreaterThan(0);
|
||||
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
|
||||
|
||||
let lastRendered = wrapper.vm.info.lastRendered;
|
||||
let duration = wrapper.vm.info.duration.value;
|
||||
|
||||
// Will not trigger a render
|
||||
wrapper.vm.hiddenCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.info.component).toBe('ComponentStub');
|
||||
expect(wrapper.vm.info.count.value).toBe(1);
|
||||
expect(wrapper.vm.info.duration.value).toBe(duration);
|
||||
expect(wrapper.vm.info.lastRendered).toBe(lastRendered);
|
||||
|
||||
// Will trigger a render
|
||||
wrapper.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.info.component).toBe('ComponentStub');
|
||||
expect(wrapper.vm.info.count.value).toBe(2);
|
||||
expect(wrapper.vm.info.duration.value).not.toBe(duration);
|
||||
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('can be used with a specific component instance', async () => {
|
||||
const wrapper = mount(NamedComponentStub);
|
||||
const instance = wrapper.vm.$;
|
||||
|
||||
const info = useRenderInfo(instance);
|
||||
|
||||
// Initial render (should be zero because the component has already rendered on mount)
|
||||
expect(info.component).toBe('ComponentStub');
|
||||
expect(info.count.value).toBe(0);
|
||||
expect(info.duration.value).toBe(0);
|
||||
expect(info.lastRendered).toBeGreaterThan(0);
|
||||
|
||||
let lastRendered = info.lastRendered;
|
||||
let duration = info.duration.value;
|
||||
|
||||
// Will not trigger a render
|
||||
wrapper.vm.hiddenCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(info.component).toBe('ComponentStub');
|
||||
expect(info.count.value).toBe(0);
|
||||
expect(info.duration.value).toBe(duration);
|
||||
expect(info.lastRendered).toBe(lastRendered);
|
||||
|
||||
// Will trigger a render
|
||||
wrapper.vm.visibleCount++;
|
||||
await nextTick();
|
||||
|
||||
expect(info.component).toBe('ComponentStub');
|
||||
expect(info.count.value).toBe(1);
|
||||
expect(info.duration.value).not.toBe(duration);
|
||||
expect(info.lastRendered).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
41
packages/vue/src/composables/useRenderInfo/index.ts
Normal file
41
packages/vue/src/composables/useRenderInfo/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
|
||||
import { useRenderCount } from '../useRenderCount';
|
||||
import { getLifeCycleTarger } from '../..';
|
||||
|
||||
/**
|
||||
* @name useRenderInfo
|
||||
* @category Components
|
||||
* @description Returns information about the component's render count and the last time it was rendered
|
||||
*
|
||||
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const { component, count, duration, lastRendered } = useRenderInfo();
|
||||
*
|
||||
* @example
|
||||
* const { component, count, duration, lastRendered } = useRenderInfo(getCurrentInstance());
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useRenderInfo(instance?: ComponentInternalInstance) {
|
||||
const target = getLifeCycleTarger(instance);
|
||||
const duration = ref(0);
|
||||
|
||||
const startMark = () => duration.value = performance.now();
|
||||
const endMark = () => duration.value = Math.max(performance.now() - duration.value, 0);
|
||||
|
||||
onBeforeMount(startMark, target);
|
||||
onMounted(endMark, target);
|
||||
|
||||
onBeforeUpdate(startMark, target);
|
||||
onUpdated(endMark, target);
|
||||
|
||||
return {
|
||||
component: target?.type.name ?? target?.uid,
|
||||
count: useRenderCount(instance),
|
||||
duration: readonly(duration),
|
||||
lastRendered: timestamp(),
|
||||
};
|
||||
}
|
||||
37
packages/vue/src/composables/useSupported/index.test.ts
Normal file
37
packages/vue/src/composables/useSupported/index.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useSupported } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
location: {
|
||||
type: String,
|
||||
default: 'location',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const isSupported = useSupported(() => props.location in window);
|
||||
|
||||
return { isSupported };
|
||||
},
|
||||
template: `<div>{{ isSupported }}</div>`,
|
||||
});
|
||||
|
||||
describe('useSupported', () => {
|
||||
it('return whether the feature is supported', async () => {
|
||||
const component = mount(ComponentStub);
|
||||
|
||||
expect(component.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('return whether the feature is not supported', async () => {
|
||||
const component = mount(ComponentStub, {
|
||||
props: {
|
||||
location: 'unsupported',
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.text()).toBe('false');
|
||||
});
|
||||
});
|
||||
29
packages/vue/src/composables/useSupported/index.ts
Normal file
29
packages/vue/src/composables/useSupported/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMounted } from '../useMounted';
|
||||
|
||||
/**
|
||||
* @name useSupported
|
||||
* @category Utilities
|
||||
* @description SSR-friendly way to check if a feature is supported
|
||||
*
|
||||
* @param {Function} feature The feature to check for support
|
||||
* @returns {ComputedRef<boolean>} Whether the feature is supported
|
||||
*
|
||||
* @example
|
||||
* const isSupported = useSupported(() => 'IntersectionObserver' in window);
|
||||
*
|
||||
* @example
|
||||
* const isSupported = useSupported(() => 'ResizeObserver' in window);
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useSupported(feature: () => unknown) {
|
||||
const isMounted = useMounted();
|
||||
|
||||
return computed(() => {
|
||||
// add reactive dependency on isMounted
|
||||
isMounted.value;
|
||||
|
||||
return Boolean(feature());
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { isArray } from '@robonen/stdlib';
|
||||
* const source = ref(0);
|
||||
* const target1 = ref(0);
|
||||
* useSyncRefs(source, target1, { immediate: true });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useSyncRefs<T = unknown>(
|
||||
source: WatchSource<T>,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isRef, ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
|
||||
|
||||
// TODO: wip
|
||||
|
||||
export interface UseToggleOptions<Enabled, Disabled> {
|
||||
enabledValue?: MaybeRefOrGetter<Enabled>,
|
||||
disabledValue?: MaybeRefOrGetter<Disabled>,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './composables';
|
||||
export * from './utils';
|
||||
export * from './utils';
|
||||
export * from './types';
|
||||
2
packages/vue/src/types/index.ts
Normal file
2
packages/vue/src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './resumable';
|
||||
export * from './window';
|
||||
30
packages/vue/src/types/resumable.ts
Normal file
30
packages/vue/src/types/resumable.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Often times, we want to pause and resume a process. This is a common pattern in
|
||||
* reactive programming. This interface defines the options and actions for a resumable
|
||||
* process.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The options for a resumable process.
|
||||
*
|
||||
* @typedef {Object} ResumableOptions
|
||||
* @property {boolean} [immediate] Whether to immediately resume the process
|
||||
*
|
||||
*/
|
||||
export interface ResumableOptions {
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions for a resumable process.
|
||||
*
|
||||
* @typedef {Object} ResumableActions
|
||||
* @property {Function} resume Resumes the process
|
||||
* @property {Function} pause Pauses the process
|
||||
* @property {Function} toggle Toggles the process
|
||||
*/
|
||||
export interface ResumableActions {
|
||||
resume: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
3
packages/vue/src/types/window.ts
Normal file
3
packages/vue/src/types/window.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { isClient } from '@robonen/platform';
|
||||
|
||||
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined
|
||||
@@ -1,5 +1,21 @@
|
||||
import { getCurrentInstance, type ComponentInternalInstance } from 'vue';
|
||||
|
||||
/**
|
||||
* @name getLifeCycleTarger
|
||||
* @category Utils
|
||||
* @description Function to get the target instance of the lifecycle hook
|
||||
*
|
||||
* @param {ComponentInternalInstance} target The target instance of the lifecycle hook
|
||||
* @returns {ComponentInternalInstance | null} Instance of the lifecycle hook or null
|
||||
*
|
||||
* @example
|
||||
* const target = getLifeCycleTarger();
|
||||
*
|
||||
* @example
|
||||
* const target = getLifeCycleTarger(instance);
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function getLifeCycleTarger(target?: ComponentInternalInstance) {
|
||||
return target || getCurrentInstance();
|
||||
}
|
||||
13
packages/vue/src/utils/error.ts
Normal file
13
packages/vue/src/utils/error.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @name VueToolsError
|
||||
* @category Error
|
||||
* @description VueToolsError is a custom error class that represents an error in Vue Tools
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export class VueToolsError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'VueToolsError';
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './components';
|
||||
export * from './error';
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"]
|
||||
}
|
||||
}
|
||||
2144
pnpm-lock.yaml
generated
2144
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@ packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
catalog:
|
||||
'@vitest/coverage-v8': ^2.1.2
|
||||
'@vitest/coverage-v8': ^2.1.3
|
||||
'@vue/test-utils': ^2.4.6
|
||||
jsdom: ^25.0.1
|
||||
pathe: ^1.1.2
|
||||
unbuild: 3.0.0-rc.8
|
||||
vitest: ^2.1.2
|
||||
vue: ^3.5.11
|
||||
unbuild: 3.0.0-rc.11
|
||||
vitest: ^2.1.3
|
||||
vue: ^3.5.12
|
||||
|
||||
Reference in New Issue
Block a user