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

Merge pull request #41 from robonen/vue-tools

Vue tools
This commit is contained in:
2024-10-23 08:02:23 +07:00
committed by GitHub
90 changed files with 2778 additions and 1255 deletions

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -0,0 +1,9 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
rollup: {
esbuild: {
// minify: true,
},
},
});

View File

@@ -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"
}

View File

@@ -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:"
}
}

View File

@@ -0,0 +1 @@
export * from './multi';

View File

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

View File

@@ -1,3 +1,6 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": ["DOM"]
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -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"
},

View File

View File

@@ -0,0 +1,3 @@
export type AsyncPoolOptions = {
concurrency?: number;
}

View File

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

View File

@@ -5,3 +5,4 @@ export * from './bits';
export * from './structs';
export * from './arrays';
export * from './types';
export * from './utils'

View File

@@ -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);
});
});

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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);

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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();

View File

@@ -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
*

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { Stack } from './index';
import { Stack } from '.';
describe('stack', () => {
describe('constructor', () => {

View File

@@ -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
*

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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);

View File

@@ -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]';

View File

@@ -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]';

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

View File

@@ -0,0 +1,9 @@
/**
* Any function
*/
export type AnyFunction = (...args: any[]) => any;
/**
* Void function
*/
export type VoidFunction = () => void;

View File

@@ -1 +1,3 @@
export * from './string';
export * from './function';
export * from './array';

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

View File

@@ -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"
},

View File

@@ -0,0 +1,10 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
externals: ['vue'],
rollup: {
esbuild: {
// minify: true,
},
},
});

View File

@@ -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"
}

View File

@@ -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:"
}

View File

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

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

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

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

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

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

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

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

View File

@@ -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);
});
});

View File

@@ -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);

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

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

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

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

View File

@@ -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();

View File

@@ -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,

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

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

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

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

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

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

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

View File

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

View File

@@ -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);
}

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

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

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

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

View File

@@ -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>,

View File

@@ -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>,

View File

@@ -1,2 +1,3 @@
export * from './composables';
export * from './utils';
export * from './types';

View File

@@ -0,0 +1,2 @@
export * from './resumable';
export * from './window';

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

View File

@@ -0,0 +1,3 @@
import { isClient } from '@robonen/platform';
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined

View File

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

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

View File

@@ -1 +1,2 @@
export * from './components';
export * from './error';

View File

@@ -1,3 +1,6 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": ["DOM"]
}
}

2144
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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