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

7 Commits

Author SHA1 Message Date
renovate[bot]
855f57cf2e chore(deps): update all non-major dependencies 2026-02-14 15:53:54 +00:00
09fe8079c0 Merge pull request #124 from robonen/linter
feat(configs/oxlint): add linter
2026-02-14 22:53:14 +07:00
ab9f45f908 refactor(ci): separate build and lint steps in CI workflow 2026-02-14 22:52:00 +07:00
49b9f2aa79 feat(configs/oxlint): add linter 2026-02-14 22:49:47 +07:00
2a5412c3b8 Merge pull request #123 from robonen/vue-composable-categories
feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories
2026-02-14 21:45:53 +07:00
5f9e0dc72d feat: add separate vitest configuration files for platform and stdlib environments 2026-02-14 21:44:54 +07:00
6565fa3de8 feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories 2026-02-14 21:38:29 +07:00
120 changed files with 2784 additions and 225 deletions

View File

@@ -31,5 +31,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Lint
run: pnpm lint
- name: Test
run: pnpm build && pnpm test
run: pnpm test

54
configs/oxlint/README.md Normal file
View File

@@ -0,0 +1,54 @@
# @robonen/oxlint
Composable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) configuration presets.
## Install
```bash
pnpm install -D @robonen/oxlint oxlint
```
## Usage
Create `oxlint.config.ts` in your project root:
```ts
import { defineConfig } from 'oxlint';
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
export default defineConfig(
compose(base, typescript, vue, vitest, imports),
);
```
Append custom rules after presets to override them:
```ts
compose(base, typescript, {
rules: { 'eslint/no-console': 'off' },
ignorePatterns: ['dist'],
});
```
## Presets
| Preset | Description |
| ------------ | -------------------------------------------------- |
| `base` | Core eslint, oxc, unicorn rules |
| `typescript` | TypeScript-specific rules (via overrides) |
| `vue` | Vue 3 Composition API / `<script setup>` rules |
| `vitest` | Test file rules (via overrides) |
| `imports` | Import rules (cycles, duplicates, ordering) |
| `node` | Node.js-specific rules |
## API
### `compose(...configs: OxlintConfig[]): OxlintConfig`
Merges multiple configs into one:
- **plugins** — union (deduplicated)
- **rules / categories** — last wins
- **overrides / ignorePatterns** — concatenated
- **env / globals** — shallow merge
- **settings** — deep merge

View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports));

View File

@@ -0,0 +1,52 @@
{
"name": "@robonen/oxlint",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "Composable oxlint configuration presets",
"keywords": [
"oxlint",
"oxc",
"linter",
"config",
"presets"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/oxlint"
},
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=22.22.0"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
},
"peerDependencies": {
"oxlint": ">=1.47.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,103 @@
import type { OxlintConfig } from './types';
/**
* Deep merge two objects. Arrays are concatenated, objects are recursively merged.
*/
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const result = { ...target };
for (const key of Object.keys(source)) {
const targetValue = target[key];
const sourceValue = source[key];
if (
typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)
&& typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
);
}
else {
result[key] = sourceValue;
}
}
return result;
}
/**
* Compose multiple oxlint configurations into a single config.
*
* - `plugins` — union (deduplicated)
* - `categories` — later configs override earlier
* - `rules` — later configs override earlier
* - `overrides` — concatenated
* - `env` — merged (later overrides earlier)
* - `globals` — merged (later overrides earlier)
* - `settings` — deep-merged
* - `ignorePatterns` — concatenated
*
* @example
* ```ts
* import { compose, base, typescript, vue } from '@robonen/oxlint';
* import { defineConfig } from 'oxlint';
*
* export default defineConfig(
* compose(base, typescript, vue, {
* rules: { 'eslint/no-console': 'off' },
* }),
* );
* ```
*/
export function compose(...configs: OxlintConfig[]): OxlintConfig {
const result: OxlintConfig = {};
for (const config of configs) {
// Plugins — union with dedup
if (config.plugins?.length) {
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
}
// Categories — shallow merge
if (config.categories) {
result.categories = { ...result.categories, ...config.categories };
}
// Rules — shallow merge (later overrides earlier)
if (config.rules) {
result.rules = { ...result.rules, ...config.rules };
}
// Overrides — concatenate
if (config.overrides?.length) {
result.overrides = [...(result.overrides ?? []), ...config.overrides];
}
// Env — shallow merge
if (config.env) {
result.env = { ...result.env, ...config.env };
}
// Globals — shallow merge
if (config.globals) {
result.globals = { ...result.globals, ...config.globals };
}
// Settings — deep merge
if (config.settings) {
result.settings = deepMerge(
(result.settings ?? {}) as Record<string, unknown>,
config.settings as Record<string, unknown>,
);
}
// Ignore patterns — concatenate
if (config.ignorePatterns?.length) {
result.ignorePatterns = [...(result.ignorePatterns ?? []), ...config.ignorePatterns];
}
}
return result;
}

View File

@@ -0,0 +1,17 @@
/* Compose */
export { compose } from './compose';
/* Presets */
export { base, typescript, vue, vitest, imports, node } from './presets';
/* Types */
export type {
OxlintConfig,
OxlintOverride,
OxlintEnv,
OxlintGlobals,
AllowWarnDeny,
DummyRule,
DummyRuleMap,
RuleCategories,
} from './types';

View File

@@ -0,0 +1,73 @@
import type { OxlintConfig } from '../types';
/**
* Base configuration for any JavaScript/TypeScript project.
*
* Enables `correctness` category and opinionated rules from
* `eslint`, `oxc`, and `unicorn` plugins.
*/
export const base: OxlintConfig = {
plugins: ['eslint', 'oxc', 'unicorn'],
categories: {
correctness: 'error',
},
rules: {
/* ── eslint core ──────────────────────────────────────── */
'eslint/eqeqeq': 'error',
'eslint/no-console': 'warn',
'eslint/no-debugger': 'error',
'eslint/no-eval': 'error',
'eslint/no-var': 'error',
'eslint/prefer-const': 'error',
'eslint/prefer-template': 'warn',
'eslint/no-useless-constructor': 'warn',
'eslint/no-useless-rename': 'warn',
'eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'eslint/no-self-compare': 'error',
'eslint/no-template-curly-in-string': 'warn',
'eslint/no-throw-literal': 'error',
'eslint/no-return-assign': 'warn',
'eslint/no-else-return': 'warn',
'eslint/no-lonely-if': 'warn',
'eslint/no-unneeded-ternary': 'warn',
'eslint/prefer-object-spread': 'warn',
'eslint/prefer-exponentiation-operator': 'warn',
'eslint/no-useless-computed-key': 'warn',
'eslint/no-useless-concat': 'warn',
'eslint/curly': 'off',
/* ── unicorn ──────────────────────────────────────────── */
'unicorn/prefer-node-protocol': 'error',
'unicorn/no-instanceof-array': 'error',
'unicorn/no-new-array': 'error',
'unicorn/prefer-array-flat-map': 'warn',
'unicorn/prefer-array-flat': 'warn',
'unicorn/prefer-includes': 'warn',
'unicorn/prefer-string-slice': 'warn',
'unicorn/prefer-string-starts-ends-with': 'warn',
'unicorn/throw-new-error': 'error',
'unicorn/error-message': 'warn',
'unicorn/no-useless-spread': 'warn',
'unicorn/no-useless-undefined': 'off',
'unicorn/prefer-optional-catch-binding': 'warn',
'unicorn/prefer-type-error': 'warn',
'unicorn/no-thenable': 'error',
'unicorn/prefer-number-properties': 'warn',
'unicorn/prefer-global-this': 'warn',
/* ── oxc ──────────────────────────────────────────────── */
'oxc/no-accumulating-spread': 'warn',
'oxc/bad-comparison-sequence': 'error',
'oxc/bad-min-max-func': 'error',
'oxc/bad-object-literal-comparison': 'error',
'oxc/const-comparisons': 'error',
'oxc/double-comparisons': 'error',
'oxc/erasing-op': 'error',
'oxc/missing-throw': 'error',
'oxc/bad-bitwise-operator': 'error',
'oxc/bad-char-at-comparison': 'error',
'oxc/bad-replace-all-arg': 'error',
},
};

View File

@@ -0,0 +1,20 @@
import type { OxlintConfig } from '../types';
/**
* Import plugin rules for clean module boundaries.
*/
export const imports: OxlintConfig = {
plugins: ['import'],
rules: {
'import/no-duplicates': 'error',
'import/no-self-import': 'error',
'import/no-cycle': 'warn',
'import/first': 'warn',
'import/no-mutable-exports': 'error',
'import/no-amd': 'error',
'import/no-commonjs': 'warn',
'import/no-empty-named-blocks': 'warn',
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
},
};

View File

@@ -0,0 +1,6 @@
export { base } from './base';
export { typescript } from './typescript';
export { vue } from './vue';
export { vitest } from './vitest';
export { imports } from './imports';
export { node } from './node';

View File

@@ -0,0 +1,17 @@
import type { OxlintConfig } from '../types';
/**
* Node.js-specific rules.
*/
export const node: OxlintConfig = {
plugins: ['node'],
env: {
node: true,
},
rules: {
'node/no-exports-assign': 'error',
'node/no-new-require': 'error',
},
};

View File

@@ -0,0 +1,39 @@
import type { OxlintConfig } from '../types';
/**
* TypeScript-specific rules.
*
* Applied via `overrides` for `*.ts`, `*.tsx`, `*.mts`, `*.cts` files.
*/
export const typescript: OxlintConfig = {
plugins: ['typescript'],
overrides: [
{
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
rules: {
'typescript/consistent-type-imports': 'error',
'typescript/no-explicit-any': 'off',
'typescript/no-non-null-assertion': 'off',
'typescript/prefer-as-const': 'error',
'typescript/no-empty-object-type': 'warn',
'typescript/no-wrapper-object-types': 'error',
'typescript/no-duplicate-enum-values': 'error',
'typescript/no-unsafe-declaration-merging': 'error',
'typescript/no-import-type-side-effects': 'error',
'typescript/no-useless-empty-export': 'warn',
'typescript/no-inferrable-types': 'warn',
'typescript/prefer-function-type': 'warn',
'typescript/ban-tslint-comment': 'error',
'typescript/consistent-type-definitions': ['warn', 'interface'],
'typescript/prefer-for-of': 'warn',
'typescript/no-unnecessary-type-constraint': 'warn',
'typescript/adjacent-overload-signatures': 'warn',
'typescript/array-type': ['warn', { default: 'array-simple' }],
'typescript/no-this-alias': 'error',
'typescript/triple-slash-reference': 'error',
'typescript/no-namespace': 'error',
},
},
],
};

View File

@@ -0,0 +1,35 @@
import type { OxlintConfig } from '../types';
/**
* Vitest rules for test files.
*
* Applied via `overrides` for common test file patterns.
*/
export const vitest: OxlintConfig = {
plugins: ['vitest'],
overrides: [
{
files: [
'**/*.test.{ts,tsx,js,jsx}',
'**/*.spec.{ts,tsx,js,jsx}',
'**/test/**/*.{ts,tsx,js,jsx}',
'**/__tests__/**/*.{ts,tsx,js,jsx}',
],
rules: {
'vitest/no-conditional-tests': 'warn',
'vitest/no-import-node-test': 'error',
'vitest/prefer-to-be-truthy': 'warn',
'vitest/prefer-to-be-falsy': 'warn',
'vitest/prefer-to-be-object': 'warn',
'vitest/prefer-to-have-length': 'warn',
'vitest/consistent-test-filename': 'warn',
'vitest/prefer-describe-function-title': 'warn',
/* relax strict rules in tests */
'eslint/no-unused-vars': 'off',
'typescript/no-explicit-any': 'off',
},
},
],
};

View File

@@ -0,0 +1,26 @@
import type { OxlintConfig } from '../types';
/**
* Vue.js-specific rules.
*
* Enforces Composition API with `<script setup>` and type-based declarations.
*/
export const vue: OxlintConfig = {
plugins: ['vue'],
rules: {
'vue/no-arrow-functions-in-watch': 'error',
'vue/no-deprecated-destroyed-lifecycle': 'error',
'vue/no-export-in-script-setup': 'error',
'vue/no-lifecycle-after-await': 'error',
'vue/no-multiple-slot-args': 'error',
'vue/no-import-compiler-macros': 'error',
'vue/define-emits-declaration': ['error', 'type-based'],
'vue/define-props-declaration': ['error', 'type-based'],
'vue/prefer-import-from-vue': 'error',
'vue/no-required-prop-with-default': 'warn',
'vue/valid-define-emits': 'error',
'vue/valid-define-props': 'error',
'vue/require-typed-ref': 'warn',
},
};

View File

@@ -0,0 +1,18 @@
/**
* Re-exported configuration types from `oxlint`.
*
* Keeps the preset API in sync with the oxlint CLI without
* maintaining a separate copy of the types.
*
* @see https://oxc.rs/docs/guide/usage/linter/config-file-reference.html
*/
export type {
OxlintConfig,
OxlintOverride,
OxlintEnv,
OxlintGlobals,
AllowWarnDeny,
DummyRule,
DummyRuleMap,
RuleCategories,
} from 'oxlint';

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import { compose } from '../src/compose';
import type { OxlintConfig } from '../src/types';
describe('compose', () => {
it('should return empty config when no configs provided', () => {
expect(compose()).toEqual({});
});
it('should return the same config when one config provided', () => {
const config: OxlintConfig = {
plugins: ['eslint'],
rules: { 'eslint/no-console': 'warn' },
};
const result = compose(config);
expect(result.plugins).toEqual(['eslint']);
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
});
it('should merge plugins with dedup', () => {
const a: OxlintConfig = { plugins: ['eslint', 'oxc'] };
const b: OxlintConfig = { plugins: ['oxc', 'typescript'] };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
});
it('should override rules from later configs', () => {
const a: OxlintConfig = { rules: { 'eslint/no-console': 'error', 'eslint/eqeqeq': 'warn' } };
const b: OxlintConfig = { rules: { 'eslint/no-console': 'off' } };
const result = compose(a, b);
expect(result.rules).toEqual({
'eslint/no-console': 'off',
'eslint/eqeqeq': 'warn',
});
});
it('should override categories from later configs', () => {
const a: OxlintConfig = { categories: { correctness: 'error', suspicious: 'warn' } };
const b: OxlintConfig = { categories: { suspicious: 'off' } };
const result = compose(a, b);
expect(result.categories).toEqual({
correctness: 'error',
suspicious: 'off',
});
});
it('should concatenate overrides', () => {
const a: OxlintConfig = {
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
};
const b: OxlintConfig = {
overrides: [{ files: ['**/*.test.ts'], rules: { 'eslint/no-unused-vars': 'off' } }],
};
const result = compose(a, b);
expect(result.overrides).toHaveLength(2);
expect(result.overrides?.[0]?.files).toEqual(['**/*.ts']);
expect(result.overrides?.[1]?.files).toEqual(['**/*.test.ts']);
});
it('should merge env', () => {
const a: OxlintConfig = { env: { browser: true } };
const b: OxlintConfig = { env: { node: true } };
const result = compose(a, b);
expect(result.env).toEqual({ browser: true, node: true });
});
it('should merge globals', () => {
const a: OxlintConfig = { globals: { MY_VAR: 'readonly' } };
const b: OxlintConfig = { globals: { ANOTHER: 'writable' } };
const result = compose(a, b);
expect(result.globals).toEqual({ MY_VAR: 'readonly', ANOTHER: 'writable' });
});
it('should deep merge settings', () => {
const a: OxlintConfig = {
settings: {
react: { version: '18.2.0' },
next: { rootDir: 'apps/' },
},
};
const b: OxlintConfig = {
settings: {
react: { linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }] },
},
};
const result = compose(a, b);
expect(result.settings).toEqual({
react: {
version: '18.2.0',
linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }],
},
next: { rootDir: 'apps/' },
});
});
it('should concatenate ignorePatterns', () => {
const a: OxlintConfig = { ignorePatterns: ['dist'] };
const b: OxlintConfig = { ignorePatterns: ['node_modules', 'coverage'] };
const result = compose(a, b);
expect(result.ignorePatterns).toEqual(['dist', 'node_modules', 'coverage']);
});
it('should handle composing all presets together', () => {
const base: OxlintConfig = {
plugins: ['eslint', 'oxc'],
categories: { correctness: 'error' },
rules: { 'eslint/no-console': 'warn' },
};
const ts: OxlintConfig = {
plugins: ['typescript'],
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
};
const custom: OxlintConfig = {
rules: { 'eslint/no-console': 'off' },
ignorePatterns: ['dist'],
};
const result = compose(base, ts, custom);
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
expect(result.categories).toEqual({ correctness: 'error' });
expect(result.rules).toEqual({ 'eslint/no-console': 'off' });
expect(result.overrides).toHaveLength(1);
expect(result.ignorePatterns).toEqual(['dist']);
});
it('should skip undefined/empty fields', () => {
const a: OxlintConfig = { plugins: ['eslint'] };
const b: OxlintConfig = { rules: { 'eslint/no-console': 'warn' } };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint']);
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
expect(result.overrides).toBeUndefined();
expect(result.env).toBeUndefined();
expect(result.settings).toBeUndefined();
});
});

View File

@@ -0,0 +1,6 @@
{
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"rootDir": "."
}
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
});

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View File

@@ -1,45 +1,27 @@
# @robonen/tsconfig
Базовый конфигурационный файл для TypeScript
Shared base TypeScript configuration.
## Установка
## Install
```bash
pnpm install -D @robonen/tsconfig
```
## Usage
Extend from it in your `tsconfig.json`:
```json
{
"extends": "@robonen/tsconfig/tsconfig.json"
}
```
## Описание основных параметров
## What's Included
```json
{
"module": "Preserve", // использовать ту же версию модуля, что и сборщик
"noEmit": true, // не генерировать файлы
"moduleResolution": "Bundler", // разрешение модулей на основе сборщика
"target": "ESNext", // целевая версия JavaScript
"skipLibCheck": true, // не проверять типы, заданные во всех файлах описания типов (*.d.ts)
"esModuleInterop": true, // создать хелперы __importStar и __importDefault для обеспечения совместимости с экосистемой Babel и включить allowSyntheticDefaultImports для совместимости с системой типов
"allowSyntheticDefaultImports": true, // разрешить импортировать модули не имеющие внутри себя "import default"
"allowJs": true, // разрешить импортировать файлы JavaScript
"resolveJsonModule": true, // разрешить импортировать файлы JSON
"moduleDetection": "force", // заставляет TypeScript рассматривать все файлы как модули. Это помогает избежать ошибок cannot redeclare block-scoped variable»
"isolatedModules": true, // орабатывать каждый файл, как отдельный изолированный модуль
"removeComments": false, // удалять комментарии из исходного кода
"verbatimModuleSyntax": true, // сохранять синтаксис модулей в исходном коде (важно при импорте типов)
"useDefineForClassFields": true, // использование классов стандарта TC39, а не TypeScript
"strict": true, // включить все строгие проверки (noImplicitAny, noImplicitThis, alwaysStrict, strictNullChecks, strictFunctionTypes, strictPropertyInitialization)
"noUncheckedIndexedAccess": true, // запрещает доступ к массиву или объекту без предварительной проверки того, определен ли он
"declaration": true, // генерировать файлы описания типов (*.d.ts)
"composite": true, // указывает TypeScript создавать файлы .tsbuildinfo. Это сообщает TypeScript, что ваш проект является частью монорепозитория, а также помогает кэшировать сборки для более быстрой работы
"sourceMap": true, // генерировать карту исходного кода
"declarationMap": true // генерировать карту исходного кода для файлов описания типов (*.d.ts)
}
```
- **Target / Module**: ESNext with Bundler resolution
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
- **Declarations**: `declaration` enabled
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`

View File

@@ -1 +1,23 @@
# @robonen/platform
# @robonen/platform
Platform-dependent utilities for browser & multi-runtime environments.
## Install
```bash
pnpm install @robonen/platform
```
## Modules
| Entry | Utilities | Description |
| ------------------ | ------------- | -------------------------------- |
| `@robonen/platform/browsers` | `focusGuard` | Browser-specific helpers |
| `@robonen/platform/multi` | `global` | Cross-runtime (Node/Bun/Deno) utilities |
## Usage
```ts
import { focusGuard } from '@robonen/platform/browsers';
import { global } from '@robonen/platform/multi';
```

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports } from '@robonen/oxlint';
export default defineConfig(
compose(base, typescript, imports, {
overrides: [
{
files: ['src/multi/global/index.ts'],
rules: {
'unicorn/prefer-global-this': 'off',
},
},
],
}),
);

View File

@@ -39,12 +39,15 @@
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}

View File

@@ -18,7 +18,7 @@
*
* @since 0.0.3
*/
export function focusGuard(namespace: string = 'focus-guard') {
export function focusGuard(namespace = 'focus-guard') {
const guardAttr = `data-${namespace}`;
const createGuard = () => {
@@ -39,7 +39,7 @@ export function focusGuard(namespace: string = 'focus-guard') {
};
}
export function createGuardAttrs(namespace: string) {
export function createGuardAttrs(namespace = 'focus-guard') {
const element = document.createElement('span');
element.setAttribute(namespace, '');

View File

@@ -1,3 +1,5 @@
// eslint-disable
export interface DebounceOptions {
/**
* Call the function on the leading edge of the timeout, instead of waiting for the trailing edge

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
},
});

View File

@@ -1 +1,32 @@
# @robonen/stdlib
# @robonen/stdlib
Standard library of platform-independent utilities for TypeScript.
## Install
```bash
pnpm install @robonen/stdlib
```
## Modules
| Module | Utilities |
| --------------- | --------------------------------------------------------------- |
| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` |
| **async** | `sleep`, `tryIt` |
| **bits** | `flags` |
| **collections** | `get` |
| **math** | `clamp`, `lerp`, `remap` + BigInt variants |
| **objects** | `omit`, `pick` |
| **patterns** | `pubsub` |
| **structs** | `stack` |
| **sync** | `mutex` |
| **text** | `levenshteinDistance`, `trigramDistance` |
| **types** | JS & TS type utilities |
| **utils** | `timestamp`, `noop` |
## Usage
```ts
import { first, sleep, clamp } from '@robonen/stdlib';
```

View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports));

View File

@@ -34,12 +34,15 @@
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}

View File

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

View File

@@ -1,3 +1,4 @@
// eslint-disable
export interface RetryOptions {
times?: number;
delay?: number;

View File

@@ -1,4 +1,4 @@
export interface BitVector {
export interface BitVectorLike {
getBit(index: number): boolean;
setBit(index: number): void;
clearBit(index: number): void;
@@ -12,7 +12,7 @@ export interface BitVector {
*
* @since 0.0.3
*/
export class BitVector extends Uint8Array implements BitVector {
export class BitVector extends Uint8Array implements BitVectorLike {
constructor(size: number) {
super(Math.ceil(size / 8));
}

View File

@@ -1,4 +1,4 @@
import { type Collection, type Path } from '../../types';
import type { Collection, Path } from '../../types';
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
K extends keyof O
@@ -9,7 +9,7 @@ export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
export type ExtractFromArray<A extends readonly any[], K> =
any[] extends A
? A extends readonly (infer T)[]
? A extends ReadonlyArray<infer T>
? T | undefined
: undefined
: K extends keyof A

View File

@@ -46,13 +46,13 @@ describe('clamp', () => {
it('handle NaN and Infinity', () => {
// value is NaN
expect(clamp(NaN, 0, 100)).toBe(NaN);
expect(clamp(Number.NaN, 0, 100)).toBe(Number.NaN);
// min is NaN
expect(clamp(50, NaN, 100)).toBe(NaN);
expect(clamp(50, Number.NaN, 100)).toBe(Number.NaN);
// max is NaN
expect(clamp(50, 0, NaN)).toBe(NaN);
expect(clamp(50, 0, Number.NaN)).toBe(Number.NaN);
// value is Infinity
expect(clamp(Infinity, 0, 100)).toBe(100);

View File

@@ -1,4 +1,5 @@
import { isArray, type Arrayable } from '../../types';
import { isArray } from '../../types';
import type { Arrayable } from '../../types';
/**
* @name omit

View File

@@ -1,4 +1,5 @@
import { isArray, type Arrayable } from '../../types';
import { isArray } from '../../types';
import type { Arrayable } from '../../types';
/**
* @name pick

View File

@@ -1,9 +1,9 @@
import { last } from '../../arrays';
import { isArray } from '../../types';
export type StackOptions = {
export interface StackOptions {
maxSize?: number;
};
}
/**
* @name Stack

View File

@@ -1,5 +1,3 @@
import type { MaybePromise } from "../../types";
/**
* @name SyncMutex
* @category Utils

View File

@@ -1,5 +1,6 @@
import { get } from '../../collections';
import { isFunction, type Path, type PathToType, type Stringable, type Trim, type UnionToIntersection } from '../../types';
import { isFunction } from '../../types';
import type { Collection, Path, PathToType, Stringable, Trim, UnionToIntersection } from '../../types';
/**
* Type of a value that will be used to replace a placeholder in a template.
@@ -55,7 +56,7 @@ export type GenerateTypes<T extends string, Target = string> = UnionToIntersecti
export function templateObject<
T extends string,
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue>
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection
>(template: T, args: A, fallback?: TemplateFallback) {
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
const value = get(args, key)?.toString();

View File

@@ -11,7 +11,7 @@ export type Trigrams = Map<string, number>;
* @since 0.0.1
*/
export function trigramProfile(text: string): Trigrams {
text = '\n\n' + text + '\n\n';
text = `\n\n${text}\n\n`;
const trigrams = new Map<string, number>();

View File

@@ -19,7 +19,7 @@ describe('casts', () => {
expect(toString(null)).toBe('[object Null]');
expect(toString(/abc/)).toBe('[object RegExp]');
expect(toString(new Date())).toBe('[object Date]');
expect(toString(new Error())).toBe('[object Error]');
expect(toString(new Error('test'))).toBe('[object Error]');
expect(toString(new Promise(() => {}))).toBe('[object Promise]');
expect(toString(new Map())).toBe('[object Map]');
expect(toString(new Set())).toBe('[object Set]');

View File

@@ -77,7 +77,7 @@ describe('complex', () => {
describe('isError', () => {
it('true if the value is an error', () => {
expect(isError(new Error())).toBe(true);
expect(isError(new Error('test'))).toBe(true);
});
it('false if the value is not an error', () => {

View File

@@ -1,4 +1,4 @@
import { toString } from '.';
import { toString } from './casts';
/**
* @name isFunction

View File

@@ -1,4 +1,4 @@
import { toString } from '.';
import { toString } from './casts';
/**
* @name isObject

View File

@@ -35,35 +35,35 @@ describe('collections', () => {
describe('PathToType', () => {
it('convert simple object path', () => {
type actual = PathToType<['user', 'name']>;
type expected = { user: { name: unknown } };
interface expected { user: { name: unknown } }
expectTypeOf<actual>().toEqualTypeOf<expected>();
});
it('convert simple array path', () => {
type actual = PathToType<['user', '0']>;
type expected = { user: unknown[] };
interface expected { user: unknown[] }
expectTypeOf<actual>().toEqualTypeOf<expected>();
});
it('convert complex object path', () => {
type actual = PathToType<['user', 'addresses', '0', 'street']>;
type expected = { user: { addresses: { street: unknown }[] } };
interface expected { user: { addresses: Array<{ street: unknown }> } }
expectTypeOf<actual>().toEqualTypeOf<expected>();
});
it('convert double dot path', () => {
type actual = PathToType<['user', '', 'name']>;
type expected = { user: { '': { name: unknown } } };
interface expected { user: { '': { name: unknown } } }
expectTypeOf<actual>().toEqualTypeOf<expected>();
});
it('convert to custom target', () => {
type actual = PathToType<['user', 'name'], string>;
type expected = { user: { name: string } };
interface expected { user: { name: string } }
expectTypeOf<actual>().toEqualTypeOf<expected>();
});

View File

@@ -20,7 +20,7 @@ export type PathToType<T extends string[], Target = unknown> =
T extends [infer Head, ...infer Rest]
? Head extends `${number}`
? Rest extends string[]
? PathToType<Rest, Target>[]
? Array<PathToType<Rest, Target>>
: never
: Rest extends string[]
? { [K in Head & string]: PathToType<Rest, Target> }

View File

@@ -7,7 +7,7 @@ describe('string', () => {
expectTypeOf(Number(1)).toExtend<Stringable>();
expectTypeOf(String(1)).toExtend<Stringable>();
expectTypeOf(Symbol()).toExtend<Stringable>();
expectTypeOf(new Array(1)).toExtend<Stringable>();
expectTypeOf([1]).toExtend<Stringable>();
expectTypeOf(new Object()).toExtend<Stringable>();
expectTypeOf(new Date()).toExtend<Stringable>();
});

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View File

@@ -1 +1,21 @@
# @robonen/renovate
# @robonen/renovate
Shared [Renovate](https://docs.renovatebot.com/) configuration preset.
## Usage
Reference it in your `renovate.json`:
```json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>robonen/tools//infra/renovate/default.json"]
}
```
## What's Included
- Extends `config:base` and `group:allNonMajor`
- Semantic commit type: `chore`
- Range strategy: `bump`
- Auto-approves & auto-merges minor, patch, pin, and digest updates (scheduled 13 AM)

View File

@@ -32,6 +32,7 @@
},
"scripts": {
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"test": "vitest run",
"test:ui": "vitest --ui",
"create": "jiti ./bin/cli.ts"

247
pnpm-lock.yaml generated
View File

@@ -18,6 +18,9 @@ catalogs:
jsdom:
specifier: ^28.0.0
version: 28.0.0
oxlint:
specifier: ^1.47.0
version: 1.47.0
tsdown:
specifier: ^0.20.3
version: 0.20.3
@@ -57,22 +60,49 @@ importers:
specifier: 'catalog:'
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
configs/oxlint:
devDependencies:
'@robonen/oxlint':
specifier: workspace:*
version: 'link:'
'@robonen/tsconfig':
specifier: workspace:*
version: link:../tsconfig
oxlint:
specifier: 'catalog:'
version: 1.47.0
tsdown:
specifier: 'catalog:'
version: 0.20.3(typescript@5.8.3)
configs/tsconfig: {}
core/platform:
devDependencies:
'@robonen/oxlint':
specifier: workspace:*
version: link:../../configs/oxlint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
oxlint:
specifier: 'catalog:'
version: 1.47.0
tsdown:
specifier: 'catalog:'
version: 0.20.3(typescript@5.8.3)
core/stdlib:
devDependencies:
'@robonen/oxlint':
specifier: workspace:*
version: link:../../configs/oxlint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
oxlint:
specifier: 'catalog:'
version: 1.47.0
tsdown:
specifier: 'catalog:'
version: 0.20.3(typescript@5.8.3)
@@ -95,12 +125,18 @@ importers:
specifier: 'catalog:'
version: 3.5.28(typescript@5.8.3)
devDependencies:
'@robonen/oxlint':
specifier: workspace:*
version: link:../../configs/oxlint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
oxlint:
specifier: 'catalog:'
version: 1.47.0
tsdown:
specifier: 'catalog:'
version: 0.20.3(typescript@5.8.3)
@@ -800,6 +836,128 @@ packages:
'@oxc-project/types@0.112.0':
resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==}
'@oxlint/binding-android-arm-eabi@1.47.0':
resolution: {integrity: sha512-UHqo3te9K/fh29brCuQdHjN+kfpIi9cnTPABuD5S9wb9ykXYRGTOOMVuSV/CK43sOhU4wwb2nT1RVjcbrrQjFw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.47.0':
resolution: {integrity: sha512-xh02lsTF1TAkR+SZrRMYHR/xCx8Wg2MAHxJNdHVpAKELh9/yE9h4LJeqAOBbIb3YYn8o/D97U9VmkvkfJfrHfw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.47.0':
resolution: {integrity: sha512-OSOfNJqabOYbkyQDGT5pdoL+05qgyrmlQrvtCO58M4iKGEQ/xf3XkkKj7ws+hO+k8Y4VF4zGlBsJlwqy7qBcHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.47.0':
resolution: {integrity: sha512-hP2bOI4IWNS+F6pVXWtRshSTuJ1qCRZgDgVUg6EBUqsRy+ExkEPJkx+YmIuxgdCduYK1LKptLNFuQLJP8voPbQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.47.0':
resolution: {integrity: sha512-F55jIEH5xmGu7S661Uho8vGiLFk0bY3A/g4J8CTKiLJnYu/PSMZ2WxFoy5Hji6qvFuujrrM9Q8XXbMO0fKOYPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.47.0':
resolution: {integrity: sha512-wxmOn/wns/WKPXUC1fo5mu9pMZPVOu8hsynaVDrgmmXMdHKS7on6bA5cPauFFN9tJXNdsjW26AK9lpfu3IfHBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.47.0':
resolution: {integrity: sha512-KJTmVIA/GqRlM2K+ZROH30VMdydEU7bDTY35fNg3tOPzQRIs2deLZlY/9JWwdWo1F/9mIYmpbdCmPqtKhWNOPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.47.0':
resolution: {integrity: sha512-PF7ELcFg1GVlS0X0ZB6aWiXobjLrAKer3T8YEkwIoO8RwWiAMkL3n3gbleg895BuZkHVlJ2kPRUwfrhHrVkD1A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.47.0':
resolution: {integrity: sha512-4BezLRO5cu0asf0Jp1gkrnn2OHiXrPPPEfBTxq1k5/yJ2zdGGTmZxHD2KF2voR23wb8Elyu3iQawXo7wvIZq0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.47.0':
resolution: {integrity: sha512-aI5ds9jq2CPDOvjeapiIj48T/vlWp+f4prkxs+FVzrmVN9BWIj0eqeJ/hV8WgXg79HVMIz9PU6deI2ki09bR1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.47.0':
resolution: {integrity: sha512-mO7ycp9Elvgt5EdGkQHCwJA6878xvo9tk+vlMfT1qg++UjvOMB8INsOCQIOH2IKErF/8/P21LULkdIrocMw9xA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.47.0':
resolution: {integrity: sha512-24D0wsYT/7hDFn3Ow32m3/+QT/1ZwrUhShx4/wRDAmz11GQHOZ1k+/HBuK/MflebdnalmXWITcPEy4BWTi7TCA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.47.0':
resolution: {integrity: sha512-8tPzPne882mtML/uy3mApvdCyuVOpthJ7xUv3b67gVfz63hOOM/bwO0cysSkPyYYFDFRn6/FnUb7Jhmsesntvg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.47.0':
resolution: {integrity: sha512-q58pIyGIzeffEBhEgbRxLFHmHfV9m7g1RnkLiahQuEvyjKNiJcvdHOwKH2BdgZxdzc99Cs6hF5xTa86X40WzPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.47.0':
resolution: {integrity: sha512-e7DiLZtETZUCwTa4EEHg9G+7g3pY+afCWXvSeMG7m0TQ29UHHxMARPaEQUE4mfKgSqIWnJaUk2iZzRPMRdga5g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.47.0':
resolution: {integrity: sha512-3AFPfQ0WKMleT/bKd7zsks3xoawtZA6E/wKf0DjwysH7wUiMMJkNKXOzYq1R/00G98JFgSU1AkrlOQrSdNNhlg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.47.0':
resolution: {integrity: sha512-cLMVVM6TBxp+N7FldQJ2GQnkcLYEPGgiuEaXdvhgvSgODBk9ov3jed+khIXSAWtnFOW0wOnG3RjwqPh0rCuheA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.47.0':
resolution: {integrity: sha512-VpFOSzvTnld77/Edje3ZdHgZWnlTb5nVWXyTgjD3/DKF/6t5bRRbwn3z77zOdnGy44xAMvbyAwDNOSeOdVUmRA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.47.0':
resolution: {integrity: sha512-+q8IWptxXx2HMTM6JluR67284t0h8X/oHJgqpxH1siowxPMqZeIpAcWCUq+tY+Rv2iQK8TUugjZnSBQAVV5CmA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -3127,6 +3285,16 @@ packages:
resolution: {integrity: sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==}
engines: {node: '>= 18.0.0'}
oxlint@1.47.0:
resolution: {integrity: sha512-v7xkK1iv1qdvTxJGclM97QzN8hHs5816AneFAQ0NGji1BMUquhiDAhXpMwp8+ls16uRVJtzVHxP9pAAXblDeGA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.11.2'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
p-all@5.0.1:
resolution: {integrity: sha512-LMT7WX9ZSaq3J1zjloApkIVmtz0ZdMFSIqbuiEa3txGYPLjUPOvgOPOx3nFjo+f37ZYL+1aY666I2SG7GVwLOA==}
engines: {node: '>=16'}
@@ -5424,6 +5592,63 @@ snapshots:
'@oxc-project/types@0.112.0': {}
'@oxlint/binding-android-arm-eabi@1.47.0':
optional: true
'@oxlint/binding-android-arm64@1.47.0':
optional: true
'@oxlint/binding-darwin-arm64@1.47.0':
optional: true
'@oxlint/binding-darwin-x64@1.47.0':
optional: true
'@oxlint/binding-freebsd-x64@1.47.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.47.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.47.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.47.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.47.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.47.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.47.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.47.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.47.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.47.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.47.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.47.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.47.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.47.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.47.0':
optional: true
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -8096,6 +8321,28 @@ snapshots:
openpgp@6.3.0:
optional: true
oxlint@1.47.0:
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.47.0
'@oxlint/binding-android-arm64': 1.47.0
'@oxlint/binding-darwin-arm64': 1.47.0
'@oxlint/binding-darwin-x64': 1.47.0
'@oxlint/binding-freebsd-x64': 1.47.0
'@oxlint/binding-linux-arm-gnueabihf': 1.47.0
'@oxlint/binding-linux-arm-musleabihf': 1.47.0
'@oxlint/binding-linux-arm64-gnu': 1.47.0
'@oxlint/binding-linux-arm64-musl': 1.47.0
'@oxlint/binding-linux-ppc64-gnu': 1.47.0
'@oxlint/binding-linux-riscv64-gnu': 1.47.0
'@oxlint/binding-linux-riscv64-musl': 1.47.0
'@oxlint/binding-linux-s390x-gnu': 1.47.0
'@oxlint/binding-linux-x64-gnu': 1.47.0
'@oxlint/binding-linux-x64-musl': 1.47.0
'@oxlint/binding-openharmony-arm64': 1.47.0
'@oxlint/binding-win32-arm64-msvc': 1.47.0
'@oxlint/binding-win32-ia32-msvc': 1.47.0
'@oxlint/binding-win32-x64-msvc': 1.47.0
p-all@5.0.1:
dependencies:
p-map: 6.0.0

View File

@@ -9,6 +9,7 @@ catalog:
'@vitest/coverage-v8': ^4.0.18
'@vue/test-utils': ^2.4.6
jsdom: ^28.0.0
oxlint: ^1.47.0
tsdown: ^0.20.3
vitest: ^4.0.18
'@vitest/ui': ^4.0.18

View File

@@ -3,16 +3,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
typecheck: {
enabled: false,
},
},
},
'configs/oxlint/vitest.config.ts',
'core/stdlib/vitest.config.ts',
'core/platform/vitest.config.ts',
'web/vue/vitest.config.ts',
],
environment: 'jsdom',
coverage: {
provider: 'v8',
include: ['core/*', 'web/*'],

View File

@@ -1 +1,28 @@
# @robonen/vue
# @robonen/vue
Collection of composables and utilities for Vue 3.
## Install
```bash
pnpm install @robonen/vue
```
## Composables
| Category | Composables |
| -------------- | ------------------------------------------------------------------ |
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
| **math** | `useClamp` |
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
| **utilities** | `useOffsetPagination` |
## Usage
```ts
import { useToggle, useEventListener } from '@robonen/vue';
```

4
web/vue/oxlint.config.ts Normal file
View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, vue, vitest, imports));

View File

@@ -32,13 +32,16 @@
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@vue/test-utils": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
},
"dependencies": {

View File

@@ -0,0 +1,3 @@
export * from './useEventListener';
export * from './useFocusGuard';
export * from './useSupported';

View File

@@ -1,6 +1,7 @@
import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib';
import { isArray, isString, noop } from '@robonen/stdlib';
import type { Arrayable, VoidFunction } from '@robonen/stdlib';
import type { MaybeRefOrGetter } from 'vue';
import { defaultWindow } from '../..';
import { defaultWindow } from '@/types';
// TODO: wip
@@ -9,9 +10,7 @@ interface InferEventTarget<Events> {
removeEventListener: (event: Events, listener?: any, options?: any) => any;
}
export interface GeneralEventListener<E = Event> {
(evt: E): void;
}
export type GeneralEventListener<E = Event> = (evt: E) => void;
export type WindowEventName = keyof WindowEventMap;
export type DocumentEventName = keyof DocumentEventMap;
@@ -19,7 +18,7 @@ export type ElementEventName = keyof HTMLElementEventMap;
/**
* @name useEventListener
* @category Elements
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 1: Omitted window target
@@ -32,7 +31,7 @@ export function useEventListener<E extends WindowEventName>(
/**
* @name useEventListener
* @category Elements
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 2: Explicit window target
@@ -46,7 +45,7 @@ export function useEventListener<E extends WindowEventName>(
/**
* @name useEventListener
* @category Elements
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 3: Explicit document target
@@ -60,7 +59,7 @@ export function useEventListener<E extends DocumentEventName>(
/**
* @name useEventListener
* @category Elements
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 4: Explicit HTMLElement target
@@ -74,7 +73,7 @@ export function useEventListener<E extends ElementEventName>(
/**
* @name useEventListener
* @category Elements
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 5: Custom target with inferred event type
@@ -84,11 +83,11 @@ export function useEventListener<Names extends string, EventType = Event>(
event: Arrayable<Names>,
listener: Arrayable<GeneralEventListener<EventType>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
)
): VoidFunction;
/**
* @name useEventListener
* @category Elements
* @category Browser
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 6: Custom event target fallback
@@ -104,13 +103,13 @@ export function useEventListener(...args: any[]) {
let target: MaybeRefOrGetter<EventTarget> | undefined;
let events: Arrayable<string>;
let listeners: Arrayable<Function>;
let options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
let _options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
if (isString(args[0]) || isArray(args[0])) {
[events, listeners, options] = args;
[events, listeners, _options] = args;
target = defaultWindow;
} else {
[target, events, listeners, options] = args;
[target, events, listeners, _options] = args;
}
if (!target)
@@ -124,13 +123,16 @@ export function useEventListener(...args: any[]) {
const cleanups: Function[] = [];
const cleanup = () => {
const _cleanup = () => {
cleanups.forEach(fn => fn());
cleanups.length = 0;
}
const register = (el: any, event: string, listener: any, options: any) => {
const _register = (el: any, event: string, listener: any, options: any) => {
el.addEventListener(event, listener, options);
return () => el.removeEventListener(event, listener, options);
}
void _cleanup;
void _register;
}

View File

@@ -17,7 +17,7 @@ const setupFocusGuard = (namespace?: string) => {
const getFocusGuards = (namespace: string) =>
document.querySelectorAll(`[data-${namespace}]`);
describe('useFocusGuard', () => {
describe(useFocusGuard, () => {
let component: ReturnType<typeof setupFocusGuard>;
const namespace = 'test-guard';
@@ -33,7 +33,7 @@ describe('useFocusGuard', () => {
component = setupFocusGuard(namespace);
const guards = getFocusGuards(namespace);
expect(guards.length).toBe(2);
expect(guards).toHaveLength(2);
guards.forEach((guard) => {
expect(guard.getAttribute('tabindex')).toBe('0');
@@ -46,7 +46,7 @@ describe('useFocusGuard', () => {
component.unmount();
expect(getFocusGuards(namespace).length).toBe(0);
expect(getFocusGuards(namespace)).toHaveLength(0);
});
it('correctly manage multiple instances with the same namespace', () => {
@@ -54,16 +54,16 @@ describe('useFocusGuard', () => {
const wrapper2 = setupFocusGuard(namespace);
// Guards should not be duplicated
expect(getFocusGuards(namespace).length).toBe(2);
expect(getFocusGuards(namespace)).toHaveLength(2);
wrapper1.unmount();
// Second instance still keeps the guards
expect(getFocusGuards(namespace).length).toBe(2);
expect(getFocusGuards(namespace)).toHaveLength(2);
wrapper2.unmount();
// No guards left after all instances are unmounted
expect(getFocusGuards(namespace).length).toBe(0);
expect(getFocusGuards(namespace)).toHaveLength(0);
});
});

View File

@@ -6,7 +6,7 @@ let counter = 0;
/**
* @name useFocusGuard
* @category Utilities
* @category Browser
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
*
* @param {string} [namespace] - A namespace to group the focus guards

View File

@@ -11,14 +11,14 @@ const ComponentStub = defineComponent({
},
},
setup(props) {
const isSupported = useSupported(() => props.location in window);
const isSupported = useSupported(() => props.location in globalThis);
return { isSupported };
},
template: `<div>{{ isSupported }}</div>`,
});
describe('useSupported', () => {
describe(useSupported, () => {
it('return whether the feature is supported', async () => {
const component = mount(ComponentStub);

View File

@@ -1,9 +1,9 @@
import { computed } from 'vue';
import { useMounted } from '../useMounted';
import { useMounted } from '@/composables/lifecycle/useMounted';
/**
* @name useSupported
* @category Utilities
* @category Browser
* @description SSR-friendly way to check if a feature is supported
*
* @param {Function} feature The feature to check for support
@@ -22,6 +22,7 @@ export function useSupported(feature: () => unknown) {
return computed(() => {
// add reactive dependency on isMounted
// eslint-disable-next-line no-unused-expressions
isMounted.value;
return Boolean(feature());

View File

@@ -0,0 +1,3 @@
export * from './unrefElement';
export * from './useRenderCount';
export * from './useRenderInfo';

View File

@@ -3,7 +3,7 @@ import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils'
import { unrefElement } from '.';
describe('unrefElement', () => {
describe(unrefElement, () => {
it('returns a plain element when passed a raw element', () => {
const htmlEl = document.createElement('div');
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
@@ -47,14 +47,14 @@ describe('unrefElement', () => {
const result = unrefElement(childInstance);
expect(result).toBe(childInstance.$el);
expect((result as HTMLElement).classList.contains('child-el')).toBe(true);
expect((result as HTMLElement).classList.contains('child-el')).toBeTruthy();
});
it('handles null and undefined values', () => {
expect(unrefElement(undefined)).toBe(undefined);
expect(unrefElement(null)).toBe(null);
expect(unrefElement(ref(null))).toBe(null);
expect(unrefElement(ref(undefined))).toBe(undefined);
expect(unrefElement(ref<null>(null))).toBe(null);
expect(unrefElement(ref<undefined>(undefined))).toBe(undefined);
expect(unrefElement(() => null)).toBe(null);
expect(unrefElement(() => undefined)).toBe(undefined);
});

View File

@@ -11,7 +11,7 @@ export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extend
/**
* @name unrefElement
* @category Components
* @category Component
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
*
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.

View File

@@ -14,7 +14,7 @@ const ComponentStub = defineComponent({
template: `<div>{{ visibleCount }}</div>`,
});
describe('useRenderCount', () => {
describe(useRenderCount, () => {
it('return the number of times the component has been rendered', async () => {
const component = mount(ComponentStub);

View File

@@ -1,10 +1,11 @@
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
import { useCounter } from '../useCounter';
import { getLifeCycleTarger } from '../..';
import { onMounted, onUpdated, readonly } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { useCounter } from '@/composables/state/useCounter';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useRenderCount
* @category Components
* @category Component
* @description Returns the number of times the component has been rendered into the DOM
*
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for

View File

@@ -26,7 +26,7 @@ const UnnamedComponentStub = defineComponent({
template: `<div>{{ visibleCount }}</div>`,
});
describe('useRenderInfo', () => {
describe(useRenderInfo, () => {
it('return uid if component name is not available', async () => {
const wrapper = mount(UnnamedComponentStub);
@@ -42,8 +42,8 @@ describe('useRenderInfo', () => {
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;
const lastRendered = wrapper.vm.info.lastRendered;
const duration = wrapper.vm.info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
@@ -76,8 +76,8 @@ describe('useRenderInfo', () => {
expect(info.duration.value).toBe(0);
expect(info.lastRendered).toBeGreaterThan(0);
let lastRendered = info.lastRendered;
let duration = info.duration.value;
const lastRendered = info.lastRendered;
const duration = info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;

View File

@@ -1,11 +1,12 @@
import { timestamp } from '@robonen/stdlib';
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { useRenderCount } from '../useRenderCount';
import { getLifeCycleTarger } from '../..';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useRenderInfo
* @category Components
* @category Component
* @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
@@ -24,7 +25,7 @@ export function useRenderInfo(instance?: ComponentInternalInstance) {
const duration = ref(0);
let renderStartTime = 0;
const startMark = () => renderStartTime = performance.now();
const startMark = () => { renderStartTime = performance.now(); };
const endMark = () => {
duration.value = Math.max(performance.now() - renderStartTime, 0);
renderStartTime = 0;

View File

@@ -1,19 +1,8 @@
export * from './tryOnBeforeMount';
export * from './tryOnMounted';
export * from './tryOnScopeDispose';
export * from './unrefElement';
export * from './useAppSharedState';
export * from './useAsyncState';
export * from './useCached';
export * from './useClamp';
export * from './useContextFactory';
export * from './useCounter';
export * from './useFocusGuard';
export * from './useInjectionStore';
export * from './useLastChanged';
export * from './useMounted';
export * from './useOffsetPagination';
export * from './useRenderCount';
export * from './useRenderInfo';
export * from './useSupported';
export * from './useSyncRefs';
export * from './browser';
export * from './component';
export * from './lifecycle';
export * from './math';
export * from './reactivity';
export * from './state';
export * from './storage';
export * from './utilities';

View File

@@ -0,0 +1,4 @@
export * from './tryOnBeforeMount';
export * from './tryOnMounted';
export * from './tryOnScopeDispose';
export * from './useMounted';

View File

@@ -1,5 +1,6 @@
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..';
import { onBeforeMount, nextTick } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '@/utils';
import type { VoidFunction } from '@robonen/stdlib';
// TODO: test
@@ -11,7 +12,7 @@ export interface TryOnBeforeMountOptions {
/**
* @name tryOnBeforeMount
* @category Components
* @category Lifecycle
* @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.

View File

@@ -1,5 +1,6 @@
import { describe, it, vi, expect } from 'vitest';
import { defineComponent, nextTick, type PropType } from 'vue';
import { defineComponent, nextTick } from 'vue';
import type { PropType } from 'vue';
import { tryOnMounted } from '.';
import { mount } from '@vue/test-utils';
import type { VoidFunction } from '@robonen/stdlib';
@@ -11,12 +12,12 @@ const ComponentStub = defineComponent({
},
},
setup(props) {
props.callback && tryOnMounted(props.callback);
if (props.callback) { tryOnMounted(props.callback); }
},
template: `<div></div>`,
});
describe('tryOnMounted', () => {
describe(tryOnMounted, () => {
it('run the callback when mounted', () => {
const callback = vi.fn();

View File

@@ -1,5 +1,6 @@
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..';
import { onMounted, nextTick } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '@/utils';
import type { VoidFunction } from '@robonen/stdlib';
// TODO: tests
@@ -11,7 +12,7 @@ export interface TryOnMountedOptions {
/**
* @name tryOnMounted
* @category Components
* @category Lifecycle
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
*
* @param {VoidFunction} fn The function to call

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, type PropType } from 'vue';
import { defineComponent, effectScope } from 'vue';
import type { PropType } from 'vue';
import { tryOnScopeDispose } from '.';
import { mount } from '@vue/test-utils';
import type { VoidFunction } from '@robonen/stdlib';
@@ -17,12 +18,12 @@ const ComponentStub = defineComponent({
template: '<div></div>',
});
describe('tryOnScopeDispose', () => {
describe(tryOnScopeDispose, () => {
it('returns false when the scope is not active', () => {
const callback = vi.fn();
const detectedScope = tryOnScopeDispose(callback);
expect(detectedScope).toBe(false);
expect(detectedScope).toBeFalsy();
expect(callback).not.toHaveBeenCalled();
});
@@ -35,7 +36,7 @@ describe('tryOnScopeDispose', () => {
detectedScope = tryOnScopeDispose(callback);
});
expect(detectedScope).toBe(true);
expect(detectedScope).toBeTruthy();
expect(callback).not.toHaveBeenCalled();
scope.stop();

View File

@@ -3,7 +3,7 @@ import { getCurrentScope, onScopeDispose } from 'vue';
/**
* @name tryOnScopeDispose
* @category Components
* @category Lifecycle
* @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.

View File

@@ -12,7 +12,7 @@ const ComponentStub = defineComponent({
template: `<div>{{ isMounted }}</div>`,
});
describe('useMounted', () => {
describe(useMounted, () => {
it('return the mounted state of the component', async () => {
const component = mount(ComponentStub);

View File

@@ -1,9 +1,10 @@
import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..';
import { onMounted, readonly, ref } from 'vue';
import type { ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '@/utils';
/**
* @name useMounted
* @category Components
* @category Lifecycle
* @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
@@ -21,7 +22,7 @@ export function useMounted(instance?: ComponentInternalInstance) {
const isMounted = ref(false);
const targetInstance = getLifeCycleTarger(instance);
onMounted(() => isMounted.value = true, targetInstance);
onMounted(() => { isMounted.value = true; }, targetInstance);
return readonly(isMounted);
}

View File

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

View File

@@ -2,7 +2,7 @@ import { ref, readonly, computed } from 'vue';
import { describe, it, expect } from 'vitest';
import { useClamp } from '.';
describe('useClamp', () => {
describe(useClamp, () => {
it('non-reactive values should be clamped', () => {
const clampedValue = useClamp(10, 0, 5);

View File

@@ -1,5 +1,6 @@
import { clamp, isFunction } from '@robonen/stdlib';
import { computed, isReadonly, ref, toValue, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type WritableComputedRef } from 'vue';
import { computed, isReadonly, ref, toValue } from 'vue';
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
/**
* @name useClamp

View File

@@ -0,0 +1,41 @@
import { customRef, onScopeDispose } from 'vue';
/**
* @name broadcastedRef
* @category Reactivity
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
*
* @param {string} key The channel key to use for broadcasting
* @param {T} initialValue The initial value of the ref
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
*
* @example
* const count = broadcastedRef('counter', 0);
*
* @since 0.0.1
*/
export function broadcastedRef<T>(key: string, initialValue: T) {
const channel = new BroadcastChannel(key);
onScopeDispose(channel.close);
return customRef<T>((track, trigger) => {
channel.onmessage = (event) => {
track();
return event.data;
};
channel.postMessage(initialValue);
return {
get() {
return initialValue;
},
set(newValue: T) {
initialValue = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
}

View File

@@ -0,0 +1,4 @@
export * from './broadcastedRef';
export * from './useCached';
export * from './useLastChanged';
export * from './useSyncRefs';

View File

@@ -4,7 +4,7 @@ import { useCached } from '.';
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
describe('useCached', () => {
describe(useCached, () => {
it('default comparator', async () => {
const externalValue = ref(0);
const cachedValue = useCached(externalValue);

View File

@@ -1,4 +1,5 @@
import { ref, watch, toValue, type MaybeRefOrGetter, type Ref, type WatchOptions } from 'vue';
import { ref, watch, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
export type Comparator<Value> = (a: Value, b: Value) => boolean;

View File

@@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest';
import { useLastChanged } from '.';
import { timestamp } from '@robonen/stdlib';
describe('useLastChanged', () => {
describe(useLastChanged, () => {
it('initialize with null if no initialValue is provided', () => {
const source = ref(0);
const lastChanged = useLastChanged(source);

View File

@@ -1,5 +1,6 @@
import { timestamp } from '@robonen/stdlib';
import { ref, watch, type WatchSource, type WatchOptions, type Ref } from 'vue';
import { ref, watch } from 'vue';
import type { WatchSource, WatchOptions, Ref } from 'vue';
export interface UseLastChangedOptions<
Immediate extends boolean,
@@ -10,7 +11,7 @@ export interface UseLastChangedOptions<
/**
* @name useLastChanged
* @category State
* @category Reactivity
* @description Records the last time a value changed
*
* @param {WatchSource} source The value to track
@@ -32,7 +33,7 @@ export function useLastChanged(source: WatchSource, options: UseLastChangedOptio
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);
watch(source, () => { lastChanged.value = timestamp(); }, options);
return lastChanged;
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useSyncRefs } from '.';
describe('useSyncRefs', () => {
describe(useSyncRefs, () => {
it('sync the value of a source ref with multiple target refs', () => {
const source = ref(0);
const target1 = ref(0);

View File

@@ -1,4 +1,5 @@
import { watch, type Ref, type WatchOptions, type WatchSource } from 'vue';
import { watch } from 'vue';
import type { Ref, WatchOptions, WatchSource } from 'vue';
import { isArray } from '@robonen/stdlib';
/**
@@ -40,7 +41,7 @@ export function useSyncRefs<T = unknown>(
return watch(
source,
(value) => targets.forEach((target) => target.value = value),
(value) => targets.forEach((target) => { target.value = value; }),
{ flush, deep, immediate },
);
}

View File

@@ -0,0 +1,6 @@
export * from './useAppSharedState';
export * from './useAsyncState';
export * from './useContextFactory';
export * from './useCounter';
export * from './useInjectionStore';
export * from './useToggle';

View File

@@ -2,7 +2,7 @@ import { describe, it, vi, expect } from 'vitest';
import { ref, reactive } from 'vue';
import { useAppSharedState } from '.';
describe('useAppSharedState', () => {
describe(useAppSharedState, () => {
it('initialize state only once', () => {
const stateFactory = (initValue?: number) => {
const count = ref(initValue ?? 0);

View File

@@ -2,7 +2,7 @@ import { isShallow, nextTick, ref } from 'vue';
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
import { useAsyncState } from '.';
describe('useAsyncState', () => {
describe(useAsyncState, () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -18,15 +18,15 @@ describe('useAsyncState', () => {
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(isReady.value).toBeTruthy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toBe(null);
});
@@ -37,15 +37,15 @@ describe('useAsyncState', () => {
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(isReady.value).toBeTruthy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toBe(null);
});
@@ -56,15 +56,15 @@ describe('useAsyncState', () => {
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(false);
expect(isReady.value).toBeFalsy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toEqual(new Error('test-error'));
});
@@ -131,14 +131,14 @@ describe('useAsyncState', () => {
);
const promise = execute();
expect(isLoading.value).toBe(true);
expect(isLoading.value).toBeTruthy();
await vi.advanceTimersByTimeAsync(50);
expect(isLoading.value).toBe(true);
expect(isLoading.value).toBeTruthy();
await vi.advanceTimersByTimeAsync(50);
await promise;
expect(isLoading.value).toBe(false);
expect(isLoading.value).toBeFalsy();
});
it('is awaitable', async () => {
@@ -160,15 +160,15 @@ describe('useAsyncState', () => {
executeImmediately();
expect(state.value).toBe('initial');
expect(isLoading.value).toBe(true);
expect(isReady.value).toBe(false);
expect(isLoading.value).toBeTruthy();
expect(isReady.value).toBeFalsy();
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(isReady.value).toBeTruthy();
expect(isLoading.value).toBeFalsy();
expect(error.value).toBe(null);
});
@@ -193,7 +193,7 @@ describe('useAsyncState', () => {
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBe(true);
expect(isShallow(state)).toBeTruthy();
});
it('uses ref when shallow is false', async () => {
@@ -204,6 +204,6 @@ describe('useAsyncState', () => {
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBe(false);
expect(isShallow(state)).toBeFalsy();
});
});

View File

@@ -1,4 +1,5 @@
import { ref, shallowRef, watch, type Ref, type ShallowRef, type UnwrapRef } from 'vue';
import { ref, shallowRef, watch } from 'vue';
import type { Ref, ShallowRef, UnwrapRef } from 'vue';
import { isFunction, sleep } from '@robonen/stdlib';
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
@@ -103,8 +104,12 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
watch(
isLoading,
(loading) => {
if (loading === false)
error.value ? reject(error.value) : resolve(shell);
if (loading === false) {
if (error.value)
reject(error.value);
else
resolve(shell);
}
},
{
immediate: true,
@@ -117,6 +122,7 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
return {
...shell,
// eslint-disable-next-line unicorn/no-thenable
then(onFulfilled, onRejected) {
return waitResolve().then(onFulfilled, onRejected);
},

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { defineComponent } from 'vue';
import { useContextFactory } from '.';
import { mount } from '@vue/test-utils';
import { VueToolsError } from '../../utils';
import { VueToolsError } from '@/utils';
function testFactory<Data>(
data: Data,
@@ -35,7 +35,7 @@ function testFactory<Data>(
// TODO: maybe replace template with passing mock functions to setup
describe('useContextFactory', () => {
describe(useContextFactory, () => {
it('provide and inject context correctly', () => {
const { Parent } = testFactory('test', useContextFactory('TestContext'));

View File

@@ -1,5 +1,6 @@
import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
import { VueToolsError } from '../..';
import { inject as vueInject, provide as vueProvide } from 'vue';
import type { InjectionKey, App } from 'vue';
import { VueToolsError } from '@/utils';
/**
* @name useContextFactory

View File

@@ -2,7 +2,7 @@ import { it, expect, describe } from 'vitest';
import { ref } from 'vue';
import { useCounter } from '.';
describe('useCounter', () => {
describe(useCounter, () => {
it('initialize count with the provided initial value', () => {
const { count } = useCounter(5);
expect(count.value).toBe(5);

View File

@@ -1,4 +1,5 @@
import { ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
import { ref, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { clamp } from '@robonen/stdlib';
export interface UseCounterOptions {
@@ -17,7 +18,7 @@ export interface UseConterReturn {
/**
* @name useCounter
* @category Utilities
* @category State
* @description A composable that provides a counter with increment, decrement, set, get, and reset functions
*
* @param {MaybeRef<number>} [initialValue=0] The initial value of the counter
@@ -46,14 +47,17 @@ export function useCounter(
max = Number.MAX_SAFE_INTEGER,
} = options;
const increment = (delta = 1) =>
const increment = (delta = 1) => {
count.value = clamp(count.value + delta, min, max);
};
const decrement = (delta = 1) =>
const decrement = (delta = 1) => {
count.value = clamp(count.value - delta, min, max);
};
const set = (value: number) =>
const set = (value: number) => {
count.value = clamp(value, min, max);
};
const get = () => count.value;

View File

@@ -1,5 +1,5 @@
import { useContextFactory } from '../useContextFactory';
import type { App, InjectionKey } from 'vue';
import type { App } from 'vue';
export interface useInjectionStoreOptions<Return> {
injectionName?: string;

Some files were not shown because too many files have changed in this diff Show More