diff --git a/.github/agents/component-test-auditor.agent.md b/.github/agents/component-test-auditor.agent.md new file mode 100644 index 0000000..2851570 --- /dev/null +++ b/.github/agents/component-test-auditor.agent.md @@ -0,0 +1,71 @@ +--- +description: "Audit and complete Vue/web component test suites. Use when the user asks to check test coverage, find missing test cases, fill gaps in component tests, verify a11y of a component, audit *.test.ts / a11y.test.ts files, or ensure a component is fully covered by vitest. Triggers: \"check tests\", \"complete tests\", \"test coverage\", \"missing test cases\", \"audit a11y\", \"проверь тесты\", \"допиши тесты\", \"покрытие тестов\"." +name: "Component Test Auditor" +tools: [read, search, edit, execute, todo] +model: "Claude Sonnet 4.5 (copilot)" +argument-hint: "Path to a component or test file to audit" +user-invocable: true +--- + +You are a specialist in Vue/web component test completeness auditing. Your single job: given a component, determine whether its test suite fully covers behavior, props, emits, slots, edge cases, and accessibility — then fill the gaps with additional vitest specs. + +## Constraints + +- DO NOT modify production component source. ONLY add or refine test files. +- DO NOT introduce new test frameworks. Use the existing `vitest` + `@vue/test-utils` setup found in the repo. +- DO NOT create redundant tests for behavior already covered. Each new spec must close a concrete gap. +- DO NOT skip running the suite — every change must be verified with vitest before reporting back. +- ONLY audit one component (or a small explicit list) per invocation. Stay focused. + +## Required Skills + +Always load these skills via `read_file` before acting: + +1. `/Users/robonen/.agents/skills/vue-testing-best-practices/SKILL.md` — patterns for Vue component tests. +2. `/Users/robonen/.agents/skills/vitest/SKILL.md` — vitest API, mocking, coverage. +3. `/Users/robonen/.agents/skills/accessibility-a11y/SKILL.md` — WCAG checks, ARIA, keyboard nav. Load whenever the component has interactive behavior, focus management, ARIA attributes, or a sibling `a11y.test.ts` file exists. +4. `/Users/robonen/Projects/tools/.github/skills/monorepo/SKILL.md` — for repo-specific commands (`pnpm`, test scoping, workspace layout). + +## Approach + +1. **Locate the component.** Resolve the target `.vue` / `.ts` source and its `__test__/` folder. Inspect sibling `*.test.ts`, `a11y.test.ts`, and any AGENTS.md / README.md for the package. +2. **Inventory the surface.** From the component source, enumerate: props (with defaults & types), emits, exposed methods, slots (named + scoped), `v-model`s, internal state machines, conditional branches, lifecycle effects, DOM/ARIA attributes, keyboard handlers. +3. **Inventory existing coverage.** Read all related test files and map each `it(...)` to the surface item it covers. Build a gap table. +4. **Decide on a11y scope.** If the component is interactive (focusable, ARIA roles, keyboard, form-related) → run the a11y skill checklist and ensure an `a11y.test.ts` exists with: role, accessible name, keyboard interaction, focus order, `aria-*` invariants, and (where applicable) `axe`-style assertions consistent with neighboring components. +5. **Write the missing specs.** Match the file/folder conventions used by neighbors (e.g. `src//__test__/.test.ts` + `a11y.test.ts`). Mirror style (mount helpers, fixtures, naming). Keep specs deterministic and isolated. +6. **Run vitest.** Use `runTests` when available, otherwise `pnpm --filter test -- `. Iterate until green. If a failure reveals a real component bug, STOP and report it — do not silently fix production code. +7. **Optionally measure coverage.** If coverage is requested or unclear, run vitest in coverage mode scoped to the component file and report uncovered lines/branches. + +## Output Format + +Return a concise markdown report: + +``` +### Component: () + +**Surface inventory** +- props: ... +- emits: ... +- slots: ... +- interactive/a11y: yes/no — + +**Existing coverage** +- : N specs — covers X, Y +- a11y.test.ts: present/absent + +**Gaps closed** +- + +- + + +**Files changed** +- (+N specs) + +**Verification** +- vitest: PASS (M tests) | coverage: L% lines / B% branches +- a11y skill applied: yes/no + +**Follow-ups (not done)** +- +``` + +If no gaps exist, say so explicitly and do not touch any files. diff --git a/.github/skills/monorepo/SKILL.md b/.github/skills/monorepo/SKILL.md new file mode 100644 index 0000000..3b5c2ff --- /dev/null +++ b/.github/skills/monorepo/SKILL.md @@ -0,0 +1,399 @@ +--- +name: monorepo +description: "Manage the @robonen/tools monorepo. Use when: installing dependencies, creating new packages, linting, building, testing, publishing, or scaffolding workspace packages. Covers pnpm catalogs, tsdown builds, eslint presets, vitest projects, JSR/NPM publishing." +--- + +# Monorepo Management + +## Overview + +This is a pnpm workspace monorepo (`@robonen/tools`) with shared configs, strict dependency management via catalogs, and automated publishing. + +## Workspace Layout + +| Directory | Purpose | Examples | +|-----------|---------|---------| +| `configs/*` | Shared tooling configs | `eslint`, `tsconfig`, `tsdown` | +| `core/*` | Platform-agnostic TS libraries | `stdlib`, `platform`, `encoding` | +| `vue/*` | Vue 3 packages | `primitives`, `toolkit` | +| `docs` | Nuxt 4 documentation site | — | +| `infra/*` | Infrastructure configs | `renovate` | + +## Installing Dependencies + +**Always use pnpm. Never use npm or yarn.** + +### Add a dependency to a specific package + +```bash +# Runtime dependency +pnpm -C add + +# Dev dependency +pnpm -C add -D +``` + +Examples: +```bash +pnpm -C core/stdlib add -D eslint +pnpm -C vue/primitives add vue +``` + +### Use catalogs for shared versions + +Versions shared across multiple packages MUST use the pnpm catalog system. The catalog is defined in `pnpm-workspace.yaml`: + +```yaml +catalog: + vitest: ^4.0.18 + tsdown: ^0.21.0 + eslint: ^10.4.1 + vue: ^3.5.28 + # ... etc +``` + +In `package.json`, reference catalog versions with the `catalog:` protocol: +```json +{ + "devDependencies": { + "vitest": "catalog:", + "eslint": "catalog:" + } +} +``` + +**When to add to catalog:** If the dependency is used in 2+ packages, add it to `pnpm-workspace.yaml` under `catalog:` and use `catalog:` in each `package.json`. + +**When NOT to use catalog:** Package-specific dependencies used in only one package (e.g., `citty` in root). + +### Internal workspace dependencies + +Reference sibling packages with the workspace protocol: +```json +{ + "devDependencies": { + "@robonen/eslint": "workspace:*", + "@robonen/tsconfig": "workspace:*", + "@robonen/tsdown": "workspace:*" + } +} +``` + +Nearly every package depends on these three shared config packages. + +### After installing + +Always run `pnpm install` at the root after editing `pnpm-workspace.yaml` or any `package.json` manually. + +## Creating a New Package + +> **Note:** The existing `bin/cli.ts` (`pnpm create`) is outdated — it generates Vite configs instead of tsdown and lacks eslint/vitest setup. Follow the manual steps below instead. + +### 1. Create the directory + +Choose the correct parent based on package type: +- `core/` — Platform-agnostic TypeScript library +- `vue/` — Vue 3 library (needs jsdom, vue deps) +- `configs/` — Shared configuration package + +### 2. Create `package.json` + +```json +{ + "name": "@robonen/", + "version": "0.0.1", + "license": "Apache-2.0", + "description": "", + "packageManager": "pnpm@10.29.3", + "engines": { "node": ">=24.13.1" }, + "type": "module", + "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "lint:check": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "dev": "vitest dev", + "build": "tsdown" + }, + "devDependencies": { + "@robonen/eslint": "workspace:*", + "@robonen/tsconfig": "workspace:*", + "@robonen/tsdown": "workspace:*", + "eslint": "catalog:", + "tsdown": "catalog:" + } +} +``` + +For Vue packages, also add: +```json +{ + "dependencies": { + "vue": "catalog:", + "@vue/shared": "catalog:" + }, + "devDependencies": { + "@vue/test-utils": "catalog:" + } +} +``` + +For packages with sub-path exports (like `core/platform`): +```json +{ + "exports": { + "./browsers": { + "types": "./dist/browsers.d.ts", + "import": "./dist/browsers.js", + "require": "./dist/browsers.cjs" + } + } +} +``` + +### 3. Create `tsconfig.json` + +Node/core package: +```json +{ + "extends": "@robonen/tsconfig/tsconfig.json" +} +``` + +Vue package (needs DOM types and path aliases): +```json +{ + "extends": "@robonen/tsconfig/tsconfig.json", + "compilerOptions": { + "lib": ["DOM"], + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + } +} +``` + +### 4. Create `tsdown.config.ts` + +Standard: +```typescript +import { defineConfig } from 'tsdown'; +import { sharedConfig } from '@robonen/tsdown'; + +export default defineConfig({ + ...sharedConfig, + entry: ['src/index.ts'], +}); +``` + +Vue package (externalize vue, bundle internal deps): +```typescript +import { defineConfig } from 'tsdown'; +import { sharedConfig } from '@robonen/tsdown'; + +export default defineConfig({ + ...sharedConfig, + entry: ['src/index.ts'], + deps: { + neverBundle: ['vue'], + alwaysBundle: [/^@robonen\//, '@vue/shared'], + }, + inputOptions: { + resolve: { + alias: { '@vue/shared': '@vue/shared/dist/shared.esm-bundler.js' }, + }, + }, + define: { __DEV__: 'false' }, +}); +``` + +### 5. Create `eslint.config.ts` + +Standard (node packages): +```typescript +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic); +``` + +> ESLint auto-discovers `eslint.config.ts` (loaded via `jiti`, which `@robonen/eslint` depends on). No `defineConfig` wrapper is needed — `compose()` returns the flat-config array directly. + +### 6. Create `vitest.config.ts` + +Node package: +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); +``` + +Vue package: +```typescript +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + define: { __DEV__: 'true' }, + resolve: { + alias: { '@': resolve(__dirname, './src') }, + }, + test: { + environment: 'jsdom', + }, +}); +``` + +### 7. Create `jsr.json` (for publishable packages) + +```json +{ + "$schema": "https://jsr.io/schema/config-file.v1.json", + "name": "@robonen/", + "version": "0.0.1", + "exports": "./src/index.ts" +} +``` + +### 8. Create source files + +```bash +mkdir -p src +touch src/index.ts +``` + +### 9. Register with vitest projects + +Add the new `vitest.config.ts` path to the root `vitest.config.ts` `projects` array. + +### 10. Install dependencies + +```bash +pnpm install +``` + +### 11. Verify + +```bash +pnpm -C build +pnpm -C lint +pnpm -C test +``` + +## Linting + +Uses **ESLint** (flat config) with composable presets from `@robonen/eslint`. + +### Run linting + +```bash +# Check lint errors (no auto-fix) +pnpm -C lint:check + +# Auto-fix lint errors +pnpm -C lint:fix + +# Check all packages +pnpm lint:check + +# Fix all packages +pnpm lint:fix +``` + +### Available presets + +| Preset | Purpose | +|--------|---------| +| `base` | ESLint core + Unicorn rules + global ignores | +| `typescript` | TypeScript rules (via `typescript-eslint`, on `*.ts` + `*.vue`) | +| `imports` | Import ordering, cycles, duplicates (`eslint-plugin-import-x`) | +| `stylistic` | Code style via `@stylistic/eslint-plugin` | +| `vue` | Vue 3 Composition API rules (`eslint-plugin-vue`) | +| `vitest` | Test file rules (`@vitest/eslint-plugin`) | +| `node` | Node.js-specific rules (`eslint-plugin-n`) | + +Compose presets in `eslint.config.ts`: +```typescript +import { base, compose, imports, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports); +``` + +**Recommended:** Include `stylistic` preset for code formatting: +```typescript +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic); +``` + +All plugins (including `@stylistic/eslint-plugin`) ship as dependencies of `@robonen/eslint`, so packages only need `@robonen/eslint` + `eslint` in devDependencies. + +## Building + +```bash +# Build a specific package +pnpm -C build + +# Build all packages +pnpm build +``` + +All packages use `tsdown` with shared config from `@robonen/tsdown`. Output: ESM (`.js`/`.mjs`) + CJS (`.cjs`) + type declarations (`.d.ts`). Every bundle includes an Apache-2.0 license banner. + +## Testing + +```bash +# Run tests in a specific package +pnpm -C test + +# Run all tests (via vitest projects) +pnpm test + +# Interactive test UI +pnpm test:ui + +# Watch mode in a package +pnpm -C dev +``` + +Uses **vitest** with project-based configuration. Root `vitest.config.ts` lists all package vitest configs as projects. + +## Publishing + +Publishing is **automated** via GitHub Actions on push to `master`: + +1. CI builds and tests all packages +2. Publish workflow compares each package's `version` in `package.json` against npm registry +3. If version changed → `pnpm publish --access public` + +**To publish:** Bump the `version` in `package.json` (and `jsr.json` if present), then merge to `master`. + +**NPM scope:** All packages publish under `@robonen/`. + +## Documentation + +```bash +pnpm docs:dev # Start Nuxt dev server +pnpm docs:generate # Generate static site +pnpm docs:preview # Preview generated site +pnpm docs:extract # Extract API docs +``` + +## Key Conventions + +- **ESM-first:** All packages use `"type": "module"` +- **Strict TypeScript:** `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true` +- **License:** Apache-2.0 for all published packages +- **Node version:** ≥24.13.1 (set in `engines` and CI) +- **pnpm version:** Pinned in `packageManager` field +- **No barrel re-exports of entire modules** — export explicitly +- **`__DEV__` global:** `false` in builds, `true` in tests (Vue packages only) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5752467..27f7dc6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,21 +5,26 @@ on: branches: - master +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + env: - NODE_VERSION: 22.x + NODE_VERSION: 24.x jobs: - code-quality: - name: Code quality checks + # Enumerate the workspace packages so the matrix below fans out one job per + # package (kept dynamic so new packages are picked up automatically). + discover: + name: Discover packages runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write + outputs: + packages: ${{ steps.list.outputs.packages }} steps: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: run_install: false @@ -31,11 +36,52 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: List workspace packages + id: list + run: echo "packages=$(pnpm -r ls --depth -1 --json | jq -c '[.[] | select(.name != "tools") | .name]')" >> "$GITHUB_OUTPUT" + + # One job per package — build (with its workspace deps), lint and test run in + # parallel across packages. fail-fast: false so every package is reported. + check: + name: ${{ matrix.package }} + needs: discover + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.discover.outputs.packages) }} + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Build the package and the workspace deps it relies on (incl. @robonen/eslint, + # which every package's lint config imports from its built dist). - name: Build - run: pnpm build + run: pnpm --filter "${{ matrix.package }}..." --if-present run build + + # Only the browser-mode test suites (vitest `instances: chromium`) need a + # browser. playwright is a direct devDep of these packages, so run its CLI + # in the package context (--filter) — it isn't resolvable from the root. + - name: Install Playwright Chromium + if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/editor' + run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium - name: Lint - run: pnpm lint + run: pnpm --filter "${{ matrix.package }}" --if-present run lint:check - name: Test - run: pnpm test \ No newline at end of file + run: pnpm --filter "${{ matrix.package }}" --if-present run test diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 3ac6de2..931f9e7 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -6,7 +6,7 @@ on: - master env: - NODE_VERSION: 22.x + NODE_VERSION: 24.x jobs: check-and-publish: @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: run_install: false @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install Playwright browser + run: pnpm --filter "@robonen/primitives" exec playwright install --with-deps chromium + - name: Build & Test run: pnpm build && pnpm test diff --git a/.gitignore b/.gitignore index a84ed06..3da90bf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ dist # test coverage +**/.vitest-attachments # env .env* diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..2148398 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "robonen-docs": { + "type": "http", + "url": "http://localhost:3000/mcp" + } + } +} diff --git a/configs/eslint/README.md b/configs/eslint/README.md new file mode 100644 index 0000000..3791415 --- /dev/null +++ b/configs/eslint/README.md @@ -0,0 +1,74 @@ +# @robonen/eslint + +Composable [ESLint](https://eslint.org) flat-config presets. + +## Install + +```bash +pnpm install -D @robonen/eslint eslint jiti +``` + +> `jiti` lets ESLint load a TypeScript `eslint.config.ts`. + +## Usage + +Create `eslint.config.ts` in your project root: + +```ts +import { compose, base, typescript, vue, vitest, imports } from '@robonen/eslint'; + +export default compose(base, typescript, vue, vitest, imports); +``` + +Append custom config objects after presets to override them: + +```ts +import { compose, base, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, { + rules: { 'no-console': 'off' }, +}, { + files: ['**/*.vue'], + rules: { '@stylistic/no-multiple-empty-lines': 'off' }, +}); +``` + +## Presets + +| Preset | Plugin(s) | Description | +| ------------ | -------------------------------------- | ---------------------------------------------- | +| `base` | `@eslint/js`, `eslint-plugin-unicorn` | Core eslint + unicorn rules, global ignores | +| `typescript` | `typescript-eslint` | TypeScript rules (`**/*.ts`, `**/*.vue`) | +| `vue` | `eslint-plugin-vue` | Vue 3 Composition API / ` + + diff --git a/configs/eslint/eslint.config.ts b/configs/eslint/eslint.config.ts new file mode 100644 index 0000000..d6b8969 --- /dev/null +++ b/configs/eslint/eslint.config.ts @@ -0,0 +1,3 @@ +import { base, compose, imports, stylistic, typescript } from './src'; + +export default compose(base, typescript, imports, stylistic); diff --git a/configs/eslint/package.json b/configs/eslint/package.json new file mode 100644 index 0000000..99e16c1 --- /dev/null +++ b/configs/eslint/package.json @@ -0,0 +1,74 @@ +{ + "name": "@robonen/eslint", + "version": "0.0.1", + "license": "Apache-2.0", + "description": "Composable ESLint flat configuration presets", + "keywords": [ + "eslint", + "eslint-config", + "flat-config", + "linter", + "config", + "presets" + ], + "author": "Robonen Andrew ", + "repository": { + "type": "git", + "url": "git+https://github.com/robonen/tools.git", + "directory": "configs/eslint" + }, + "packageManager": "pnpm@10.33.2", + "engines": { + "node": ">=24.13.1" + }, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "lint:check": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "dev": "vitest dev", + "build": "tsdown" + }, + "dependencies": { + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "catalog:", + "@vitest/eslint-plugin": "^1.6.19", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-n": "^18.0.1", + "eslint-plugin-regexp": "^3.1.0", + "eslint-plugin-unicorn": "^65.0.0", + "eslint-plugin-vue": "^10.9.2", + "globals": "^17.6.0", + "jiti": "^2.7.0", + "typescript-eslint": "^8.60.1", + "vue-eslint-parser": "^10.4.1" + }, + "devDependencies": { + "@robonen/eslint": "workspace:*", + "@robonen/tsconfig": "workspace:*", + "@robonen/tsdown": "workspace:*", + "eslint": "catalog:", + "tsdown": "catalog:" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/configs/eslint/rules/README.md b/configs/eslint/rules/README.md new file mode 100644 index 0000000..92c9c0a --- /dev/null +++ b/configs/eslint/rules/README.md @@ -0,0 +1,21 @@ +# Rules Reference + +Документация по preset-ам `@robonen/eslint`: что включает каждый preset и какие правила чаще всего влияют на код. + +## Presets + +- [base](./base.md) +- [typescript](./typescript.md) +- [vue](./vue.md) +- [vitest](./vitest.md) +- [imports](./imports.md) +- [node](./node.md) +- [stylistic](./stylistic.md) + +## Как читать + +- `Purpose` — зачем preset подключать. +- `Key Rules` — ключевые правила из preset-а (не полный dump). +- `Examples` — минимальные `good/bad` примеры. + +Для точного источника правил см. файлы в `configs/eslint/src/presets/*.ts`. diff --git a/configs/eslint/rules/base.md b/configs/eslint/rules/base.md new file mode 100644 index 0000000..65579d9 --- /dev/null +++ b/configs/eslint/rules/base.md @@ -0,0 +1,36 @@ +# base preset + +## Purpose + +Базовый quality-профиль для JS/TS-проектов: корректность, анти-паттерны, безопасные дефолты. Включает `@eslint/js` recommended (аналог oxlint-категории `correctness`) + правила `unicorn`. + +## Key Rules + +- `eqeqeq`: запрещает `==`, требует `===`. +- `no-unused-vars`: запрещает неиспользуемые переменные (кроме `_name`). +- `no-eval`, `no-var`, `prefer-const`. +- `unicorn/prefer-node-protocol`: требует `node:` для built-in модулей. +- `unicorn/no-thenable`: запрещает thenable-объекты. + +> Правила `oxc/*` из `@robonen/oxlint` не имеют аналога в ESLint и были убраны; +> их назначение покрывается `@eslint/js` recommended и `unicorn`. + +## Examples + +```ts +// ✅ Good +import { readFile } from 'node:fs/promises'; + +const id = 42; +if (id === 42) { + throw new Error('unexpected'); +} + +// ❌ Bad +import { readFile } from 'fs/promises'; + +var id = 42; +if (id == '42') { + throw 'unexpected'; +} +``` diff --git a/configs/eslint/rules/imports.md b/configs/eslint/rules/imports.md new file mode 100644 index 0000000..101111b --- /dev/null +++ b/configs/eslint/rules/imports.md @@ -0,0 +1,27 @@ +# imports preset + +## Purpose + +Чистые границы модулей и предсказуемые импорты (через `eslint-plugin-import-x`). + +## Key Rules + +- `import-x/no-duplicates`. +- `import-x/no-self-import`. +- `import-x/no-cycle` (warn). +- `import-x/no-mutable-exports`. +- `import-x/consistent-type-specifier-style`: `prefer-top-level`. + +## Examples + +```ts +// ✅ Good +import type { User } from './types'; +import { getUser } from './service'; + +// ❌ Bad +import { getUser } from './service'; +import { getUser as getUser2 } from './service'; + +export let state = 0; +``` diff --git a/configs/eslint/rules/node.md b/configs/eslint/rules/node.md new file mode 100644 index 0000000..9aa8181 --- /dev/null +++ b/configs/eslint/rules/node.md @@ -0,0 +1,22 @@ +# node preset + +## Purpose + +Node.js-правила (через `eslint-plugin-n`) и Node-глобалы в `languageOptions.globals`. + +## Key Rules + +- `n/no-exports-assign`: запрещает перезапись `exports`. +- `n/no-new-require`: запрещает `new require(...)`. + +## Examples + +```ts +// ✅ Good +module.exports = { run }; +const mod = require('./mod'); + +// ❌ Bad +exports = { run }; +const bad = new require('./mod'); +``` diff --git a/configs/eslint/rules/stylistic.md b/configs/eslint/rules/stylistic.md new file mode 100644 index 0000000..683adcc --- /dev/null +++ b/configs/eslint/rules/stylistic.md @@ -0,0 +1,51 @@ +# stylistic preset + +## Purpose + +Форматирование через `@stylistic/eslint-plugin` (отступы, пробелы, скобки, переносы, TS/JSX-стиль). + +## Defaults + +- `indent: 2` +- `quotes: single` +- `semi: always` +- `braceStyle: stroustrup` +- `commaDangle: always-multiline` +- `arrowParens: as-needed` + +## Key Rules + +- `@stylistic/indent`, `@stylistic/no-tabs`. +- `@stylistic/quotes`, `@stylistic/semi`. +- `@stylistic/object-curly-spacing`, `@stylistic/comma-spacing`. +- `@stylistic/arrow-spacing`, `@stylistic/space-before-function-paren`. +- `@stylistic/max-statements-per-line`. +- `@stylistic/no-mixed-operators`. +- `@stylistic/member-delimiter-style` (TS). + +## Examples + +```ts +// ✅ Good +type User = { + id: string; + role: 'admin' | 'user'; +}; + +const value = condition + ? 'yes' + : 'no'; + +const sum = (a: number, b: number) => a + b; + +// ❌ Bad +type User = { + id: string + role: 'admin' | 'user' +} + +const value = condition ? 'yes' : 'no'; const x = 1; +const sum=(a:number,b:number)=>{ return a+b }; +``` + +Полный список правил и их настройки см. в `src/presets/stylistic.ts`. diff --git a/configs/eslint/rules/typescript.md b/configs/eslint/rules/typescript.md new file mode 100644 index 0000000..d3377a7 --- /dev/null +++ b/configs/eslint/rules/typescript.md @@ -0,0 +1,33 @@ +# typescript preset + +## Purpose + +TypeScript-правила для `.ts/.tsx/.mts/.cts` и ` + + + +``` diff --git a/configs/eslint/src/compose.ts b/configs/eslint/src/compose.ts new file mode 100644 index 0000000..90ef126 --- /dev/null +++ b/configs/eslint/src/compose.ts @@ -0,0 +1,34 @@ +import type { FlatConfigArray, FlatConfigInput } from './types'; + +/** + * Compose multiple ESLint flat configurations into a single flat config array. + * + * ESLint flat config is an ordered array where later entries override earlier + * ones, so composition is a flatten: each preset (an array) and each inline + * override (a single object) are concatenated in order. `undefined`/`null` + * inputs are skipped, allowing conditional spreads. + * + * @example + * ```ts + * import { compose, base, typescript, vue } from '@robonen/eslint'; + * + * export default compose(base, typescript, vue, { + * rules: { 'no-console': 'off' }, + * }); + * ``` + */ +export function compose(...configs: Array): FlatConfigArray { + const result: FlatConfigArray = []; + + for (const config of configs) { + if (!config) + continue; + + if (Array.isArray(config)) + result.push(...config); + else + result.push(config); + } + + return result; +} diff --git a/configs/eslint/src/index.ts b/configs/eslint/src/index.ts new file mode 100644 index 0000000..498a123 --- /dev/null +++ b/configs/eslint/src/index.ts @@ -0,0 +1,13 @@ +/* Compose */ +export { compose } from './compose'; + +/* Presets */ +export { base, ignores, typescript, vue, vitest, imports, node, stylistic, regexp } from './presets'; + +/* Types */ +export type { + FlatConfig, + FlatConfigArray, + FlatConfigInput, + Rules, +} from './types'; diff --git a/configs/eslint/src/presets/base.ts b/configs/eslint/src/presets/base.ts new file mode 100644 index 0000000..4da972a --- /dev/null +++ b/configs/eslint/src/presets/base.ts @@ -0,0 +1,128 @@ +import type { FlatConfigArray } from '../types'; +import js from '@eslint/js'; +import unicorn from 'eslint-plugin-unicorn'; +import globals from 'globals'; +import { regexp } from './regexp'; + +/** + * Globally ignored paths — build output, coverage, generated artifacts. + * + * A flat config entry with only `ignores` acts as a global ignore. + */ +export const ignores: FlatConfigArray = [ + { + name: 'robonen/ignores', + ignores: [ + '**/dist/**', + '**/coverage/**', + '**/node_modules/**', + '**/.nuxt/**', + '**/.output/**', + '**/storybook-static/**', + '**/*.min.*', + // Hand-authored docs-site content co-located in packages (intro/guide .vue + // pages and per-composable demos). Not shipped source; lints against the + // docs app's own toolchain, and non-Vue packages can't even parse .vue. + '**/docs/**', + '**/demo.vue', + ], + }, +]; + +/** + * Base configuration for any JavaScript/TypeScript project. + * + * Includes `@eslint/js` recommended rules (the analog of oxlint's + * `correctness` category) plus opinionated `eslint` core and `unicorn` rules. + * + * > Note: oxlint's `oxc/*` rules have no ESLint equivalent and are dropped in + * > this migration; their intent is largely covered by `@eslint/js` recommended + * > and `unicorn`. + */ +export const base: FlatConfigArray = [ + ...ignores, + { + name: 'robonen/base/setup', + languageOptions: { + ecmaVersion: 2024, + sourceType: 'module', + globals: { + ...globals.es2024, + ...globals.browser, + ...globals.node, + }, + }, + }, + js.configs.recommended, + { + name: 'robonen/base/rules', + plugins: { + unicorn, + }, + rules: { + /* ── eslint core ──────────────────────────────────────── */ + eqeqeq: 'error', + 'no-console': 'warn', + 'no-debugger': 'error', + 'no-eval': 'error', + 'no-var': 'error', + 'prefer-const': 'error', + 'prefer-template': 'error', + 'no-useless-constructor': 'error', + 'no-useless-rename': 'error', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-self-compare': 'error', + 'no-template-curly-in-string': 'error', + 'no-throw-literal': 'error', + 'no-return-assign': 'error', + 'no-else-return': 'error', + 'no-lonely-if': 'error', + 'no-unneeded-ternary': 'error', + 'prefer-object-spread': 'error', + 'prefer-exponentiation-operator': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-concat': 'error', + 'no-array-constructor': 'error', + 'no-new-wrappers': 'error', + 'no-useless-return': 'error', + 'object-shorthand': ['error', 'always'], + 'prefer-spread': 'error', + 'prefer-rest-params': 'error', + 'symbol-description': 'error', + curly: 'off', + + /* ── unicorn ──────────────────────────────────────────── */ + 'unicorn/prefer-node-protocol': 'error', + 'unicorn/no-instanceof-builtins': 'error', + 'unicorn/no-new-array': 'error', + 'unicorn/prefer-array-flat-map': 'error', + 'unicorn/prefer-array-flat': 'error', + 'unicorn/prefer-includes': 'error', + 'unicorn/prefer-string-slice': 'error', + 'unicorn/prefer-string-starts-ends-with': 'error', + 'unicorn/throw-new-error': 'error', + 'unicorn/error-message': 'error', + 'unicorn/no-useless-spread': 'error', + 'unicorn/no-useless-undefined': 'off', + 'unicorn/prefer-optional-catch-binding': 'error', + 'unicorn/prefer-type-error': 'error', + 'unicorn/no-thenable': 'error', + 'unicorn/prefer-number-properties': 'error', + 'unicorn/prefer-global-this': 'error', + 'unicorn/prefer-array-some': 'error', + 'unicorn/prefer-array-find': 'error', + 'unicorn/prefer-array-index-of': 'error', + 'unicorn/prefer-date-now': 'error', + 'unicorn/prefer-modern-math-apis': 'error', + 'unicorn/prefer-negative-index': 'error', + 'unicorn/prefer-set-has': 'error', + 'unicorn/prefer-string-trim-start-end': 'error', + 'unicorn/prefer-regexp-test': 'error', + 'unicorn/prefer-string-replace-all': 'error', + 'unicorn/no-typeof-undefined': 'error', + 'unicorn/no-array-push-push': 'error', + 'unicorn/no-useless-promise-resolve-reject': 'error', + }, + }, + ...regexp, +]; diff --git a/configs/eslint/src/presets/imports.ts b/configs/eslint/src/presets/imports.ts new file mode 100644 index 0000000..fa30055 --- /dev/null +++ b/configs/eslint/src/presets/imports.ts @@ -0,0 +1,55 @@ +import type { FlatConfigArray } from '../types'; +import importX, { createNodeResolver } from 'eslint-plugin-import-x'; + +/** + * Import configuration for clean module boundaries. + * + * Uses `eslint-plugin-import-x` (the faster, modern fork) under the `import-x` + * namespace, plus the core `sort-imports` rule. The Node resolver is configured + * explicitly (flat config no longer ships a default) with TypeScript/Vue-aware + * extensions so resolution-based rules (`no-cycle`, `no-self-import`) work. + */ +export const imports: FlatConfigArray = [ + { + name: 'robonen/imports', + plugins: { + 'import-x': importX, + }, + settings: { + 'import-x/resolver-next': [ + createNodeResolver({ + extensions: ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.vue', '.json', '.node'], + }), + ], + }, + rules: { + 'import-x/no-duplicates': 'error', + 'import-x/no-self-import': 'error', + 'import-x/no-cycle': 'error', + 'import-x/first': 'error', + 'import-x/no-mutable-exports': 'error', + 'import-x/no-amd': 'error', + 'import-x/no-commonjs': 'error', + 'import-x/no-empty-named-blocks': 'error', + 'import-x/no-useless-path-segments': ['error', { noUselessIndex: false }], + 'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'], + + /* Only enforce member order within `{ … }`; declaration order is sorted + by source path across the codebase, which core `sort-imports` (orders + by first member name) would otherwise fight. Kept at `warn` — it is not + autofixable and member order is a soft preference. */ + 'sort-imports': ['warn', { ignoreDeclarationSort: true }], + }, + }, + { + /* Vue SFCs may have two + + diff --git a/configs/tsconfig/package.json b/configs/tsconfig/package.json index 8287506..5178487 100644 --- a/configs/tsconfig/package.json +++ b/configs/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "@robonen/tsconfig", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "description": "Base typescript configuration for projects", "keywords": [ @@ -15,12 +15,16 @@ "url": "git+https://github.com/robonen/tools.git", "directory": "packages/tsconfig" }, - "packageManager": "pnpm@10.29.3", + "packageManager": "pnpm@10.33.2", "engines": { "node": ">=24.13.1" }, "files": [ - "**tsconfig.json" + "tsconfig.json", + "tsconfig.base.json", + "tsconfig.dom.json", + "tsconfig.node.json", + "tsconfig.vue.json" ], "publishConfig": { "access": "public" diff --git a/configs/tsconfig/tsconfig.base.json b/configs/tsconfig/tsconfig.base.json new file mode 100644 index 0000000..09a83e2 --- /dev/null +++ b/configs/tsconfig/tsconfig.base.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@robonen base (type-check, no DOM)", + "compilerOptions": { + /* Modules */ + "module": "Preserve", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + + /* Language and environment */ + "target": "ESNext", + "lib": ["ESNext"], + "useDefineForClassFields": true, + + /* Type checking (strict) */ + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "forceConsistentCasingInFileNames": true, + + /* Emit is delegated to the bundler (tsdown); tsc is type-check only */ + "noEmit": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", "dist", ".output", "coverage"] +} diff --git a/configs/tsconfig/tsconfig.dom.json b/configs/tsconfig/tsconfig.dom.json new file mode 100644 index 0000000..8478baf --- /dev/null +++ b/configs/tsconfig/tsconfig.dom.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@robonen dom (browser libraries)", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"] + } +} diff --git a/configs/tsconfig/tsconfig.json b/configs/tsconfig/tsconfig.json index a5ed60d..bd66768 100644 --- a/configs/tsconfig/tsconfig.json +++ b/configs/tsconfig/tsconfig.json @@ -1,36 +1,5 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "display": "Base TypeScript Configuration", - "compilerOptions": { - /* Basic Options */ - "module": "ESNext", - "noEmit": true, - "lib": ["ESNext"], - "moduleResolution": "Bundler", - "target": "ESNext", - "outDir": "dist", - - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "allowImportingTsExtensions": true, - "allowJs": true, - "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - "removeComments": false, - "verbatimModuleSyntax": true, - "useDefineForClassFields": true, - - /* Strict Type-Checking Options */ - "strict": true, - "noUncheckedIndexedAccess": true, - - /* Library transpiling */ - "declaration": true, - // "composite": true, - "sourceMap": false, - "declarationMap": false - }, - "exclude": ["node_modules", "dist", ".output", "coverage"] -} \ No newline at end of file + "display": "@robonen base (default)", + "extends": "./tsconfig.base.json" +} diff --git a/configs/tsconfig/tsconfig.node.json b/configs/tsconfig/tsconfig.node.json new file mode 100644 index 0000000..33df2c9 --- /dev/null +++ b/configs/tsconfig/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@robonen node (build/test tooling files)", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext"], + "types": ["node"] + } +} diff --git a/configs/tsconfig/tsconfig.vue.json b/configs/tsconfig/tsconfig.vue.json new file mode 100644 index 0000000..59c79f9 --- /dev/null +++ b/configs/tsconfig/tsconfig.vue.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@robonen vue (Vue SFC libraries / apps)", + "extends": "./tsconfig.dom.json", + "compilerOptions": { + "jsx": "preserve" + }, + "vueCompilerOptions": { + "strictTemplates": true, + "fallthroughAttributes": true, + "inferTemplateDollarAttrs": true, + "inferTemplateDollarEl": true, + "inferTemplateDollarRefs": true + } +} diff --git a/configs/tsdown/docs/intro.vue b/configs/tsdown/docs/intro.vue new file mode 100644 index 0000000..c282f9c --- /dev/null +++ b/configs/tsdown/docs/intro.vue @@ -0,0 +1,121 @@ + + + diff --git a/configs/tsdown/package.json b/configs/tsdown/package.json index 44a7213..e954910 100644 --- a/configs/tsdown/package.json +++ b/configs/tsdown/package.json @@ -15,7 +15,7 @@ "url": "git+https://github.com/robonen/tools.git", "directory": "configs/tsdown" }, - "packageManager": "pnpm@10.29.3", + "packageManager": "pnpm@10.33.2", "engines": { "node": ">=24.13.1" }, diff --git a/configs/tsdown/src/index.ts b/configs/tsdown/src/index.ts index 4f69eb2..db3d8f6 100644 --- a/configs/tsdown/src/index.ts +++ b/configs/tsdown/src/index.ts @@ -1,4 +1,4 @@ -import type { Options } from 'tsdown'; +import type { InlineConfig } from 'tsdown'; const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */'; @@ -10,4 +10,4 @@ export const sharedConfig = { outputOptions: { banner: BANNER, }, -} satisfies Options; +} satisfies InlineConfig; diff --git a/core/crdt/README.md b/core/crdt/README.md new file mode 100644 index 0000000..a9a61a2 --- /dev/null +++ b/core/crdt/README.md @@ -0,0 +1,60 @@ +# @robonen/crdt + +Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser. + +Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests. + +## Primitives + +| Module | Exports | Purpose | +| --- | --- | --- | +| `clock` | `OpId`, `LamportClock`, `VersionVector`, `compareOpId`, `createSiteId` | Causality: per-site Lamport ids with a deterministic total order; version vectors for dedup + deltas. | +| `registers` | `LwwRegister`, `LwwMap` | Last-writer-wins values / maps (conflict resolved by op id). | +| `ordering` | `keyBetween`, `keysBetween` | Fractional indexing — place an item strictly between two neighbors (or move it) with one string key. | +| `sequence` | `Rga` | Replicated Growable Array — a sequence CRDT with tombstones, higher-op-id-first tie-break, causal-buffering API. | +| `marks` | `MarkStore`, `MarkSpan`, `MarkValue` | Lightweight Peritext: formatting spans anchored to character op ids, resolved per character by highest op id. | +| `oplog` | `OpLog` | Append-only op log with a version vector; computes deltas. | +| `sync` | `encodeStateVector`, `encodeDelta`/`encodeOps`, `decode*` | Transport-agnostic wire encoding (JSON-over-bytes in v1). | +| `doc` | `Replica` | Ties a clock + op log + causal buffer together; integrates local/remote ops and exposes deltas. | + +## Example: a converging replicated string + +```ts +import { Replica, Rga, opId } from '@robonen/crdt'; + +function makeReplica(site: string) { + const rga = new Rga(); + const replica = new Replica<{ id: ReturnType; originLeft: ReturnType | null; value: string }>( + { integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) }, + site, + ); + return { rga, replica }; +} + +const a = makeReplica('a'); +const b = makeReplica('b'); + +// ... A and B make concurrent local edits via replica.commitLocal(...) ... + +// Exchange only what each side is missing: +b.replica.receive(a.replica.delta(b.replica.version)); +a.replica.receive(b.replica.delta(a.replica.version)); + +a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged +``` + +`Replica.receive` buffers ops whose causal dependencies haven't arrived yet (an insert before its origin, a delete before its target) and retries them automatically. + +## Notes + +- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge. +- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …). +- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change. +- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic. + +## Development + +```bash +pnpm --filter @robonen/crdt test # property/convergence tests +pnpm --filter @robonen/crdt build # tsdown (ESM + CJS + dts) +``` diff --git a/core/crdt/docs/01-concepts.vue b/core/crdt/docs/01-concepts.vue new file mode 100644 index 0000000..80c83cd --- /dev/null +++ b/core/crdt/docs/01-concepts.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/core/crdt/docs/02-primitives.vue b/core/crdt/docs/02-primitives.vue new file mode 100644 index 0000000..77e29a4 --- /dev/null +++ b/core/crdt/docs/02-primitives.vue @@ -0,0 +1,435 @@ + + + + + diff --git a/core/crdt/docs/03-replication.vue b/core/crdt/docs/03-replication.vue new file mode 100644 index 0000000..8dfb6d6 --- /dev/null +++ b/core/crdt/docs/03-replication.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/core/crdt/docs/04-playground.vue b/core/crdt/docs/04-playground.vue new file mode 100644 index 0000000..45b514f --- /dev/null +++ b/core/crdt/docs/04-playground.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/vue/toolkit/src/composables/elements/useElementSize/index.test.ts b/vue/toolkit/src/composables/elements/useElementSize/index.test.ts new file mode 100644 index 0000000..140fcb2 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useElementSize/index.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useElementSize } from '.'; + +interface StubInstance { + cb: ResizeObserverCallback; + observe: ReturnType; + disconnect: ReturnType; + unobserve: ReturnType; +} + +let instances: StubInstance[] = []; + +class StubResizeObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + instances.push(this); + } +} + +function fire(width: number, height: number, fields: Partial = {}) { + instances[0]!.cb([ + { + contentBoxSize: [{ inlineSize: width, blockSize: height }], + contentRect: { width, height }, + ...fields, + } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); +} + +describe(useElementSize, () => { + beforeEach(() => { + instances = []; + vi.stubGlobal('ResizeObserver', StubResizeObserver); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('uses the initial size when the target resolves to no element', () => { + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(undefined), { width: 5, height: 7 }); + }); + + expect(size!.width.value).toBe(5); + expect(size!.height.value).toBe(7); + scope.stop(); + }); + + it('measures synchronously on mount via offset size', () => { + const el = document.createElement('div'); + Object.defineProperty(el, 'offsetWidth', { value: 80, configurable: true }); + Object.defineProperty(el, 'offsetHeight', { value: 60, configurable: true }); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el), { width: 5, height: 7 }); + }); + + // tryOnMounted runs synchronously outside a component, overwriting the initial size. + expect(size!.width.value).toBe(80); + expect(size!.height.value).toBe(60); + scope.stop(); + }); + + it('reports size from contentBoxSize', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el)); + }); + + instances[0]!.cb([ + { contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); + + expect(size!.width.value).toBe(100); + expect(size!.height.value).toBe(50); + scope.stop(); + }); + + it('falls back to contentRect when box sizes are missing', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el)); + }); + + instances[0]!.cb([ + { contentBoxSize: undefined, contentRect: { width: 30, height: 40 } } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); + + expect(size!.width.value).toBe(30); + expect(size!.height.value).toBe(40); + scope.stop(); + }); + + it('normalises a single (non-array) ResizeObserverSize object', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el)); + }); + + // Older Firefox reports box sizes as a single object rather than an array. + instances[0]!.cb([ + { contentBoxSize: { inlineSize: 12, blockSize: 34 }, contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); + + expect(size!.width.value).toBe(12); + expect(size!.height.value).toBe(34); + scope.stop(); + }); + + it('sums multiple box fragments in a single pass', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el)); + }); + + instances[0]!.cb([ + { + contentBoxSize: [ + { inlineSize: 10, blockSize: 5 }, + { inlineSize: 20, blockSize: 7 }, + ], + contentRect: { width: 0, height: 0 }, + } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); + + expect(size!.width.value).toBe(30); + expect(size!.height.value).toBe(12); + scope.stop(); + }); + + it('reads borderBoxSize when box is "border-box"', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el), { width: 0, height: 0 }, { box: 'border-box' }); + }); + + instances[0]!.cb([ + { + borderBoxSize: [{ inlineSize: 200, blockSize: 120 }], + contentBoxSize: [{ inlineSize: 1, blockSize: 1 }], + contentRect: { width: 0, height: 0 }, + } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); + + expect(size!.width.value).toBe(200); + expect(size!.height.value).toBe(120); + scope.stop(); + }); + + it('measures SVG elements via getBoundingClientRect', () => { + const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + el.getBoundingClientRect = () => ({ width: 64, height: 48 }) as DOMRect; + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(ref(el), { width: 0, height: 0 }, { window: globalThis as unknown as Window }); + }); + + // Even though the entry advertises a different box size, the SVG path wins. + instances[0]!.cb([ + { contentBoxSize: [{ inlineSize: 999, blockSize: 999 }], contentRect: { width: 999, height: 999 } } as unknown as ResizeObserverEntry, + ], {} as ResizeObserver); + + expect(size!.width.value).toBe(64); + expect(size!.height.value).toBe(48); + scope.stop(); + }); + + it('resets to 0 when the element detaches', async () => { + const el = ref(document.createElement('div')); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(el, { width: 5, height: 7 }); + }); + + fire(100, 50); + expect(size!.width.value).toBe(100); + + el.value = undefined; + await nextTick(); + + expect(size!.width.value).toBe(0); + expect(size!.height.value).toBe(0); + scope.stop(); + }); + + it('stop() disconnects the observer and the detach watcher', async () => { + const el = ref(document.createElement('div')); + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useElementSize(el, { width: 0, height: 0 }); + }); + + await nextTick(); + expect(instances[0]!.disconnect).not.toHaveBeenCalled(); + + fire(100, 50); + expect(size!.width.value).toBe(100); + + size!.stop(); + // The observer is torn down so it stops delivering callbacks in a real browser. + expect(instances[0]!.disconnect).toHaveBeenCalled(); + + // The detach watcher is also stopped: clearing the target no longer resets the size to 0. + el.value = undefined; + await nextTick(); + expect(size!.width.value).toBe(100); + expect(size!.height.value).toBe(50); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useElementSize/index.ts b/vue/toolkit/src/composables/elements/useElementSize/index.ts new file mode 100644 index 0000000..719df8f --- /dev/null +++ b/vue/toolkit/src/composables/elements/useElementSize/index.ts @@ -0,0 +1,121 @@ +import { computed, shallowRef, watch } from 'vue'; +import type { ShallowRef } from 'vue'; +import { toArray } from '@robonen/stdlib'; +import type { ConfigurableWindow } from '@/types'; +import { defaultWindow } from '@/types'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { unrefElement } from '@/composables/component/unrefElement'; +import { useResizeObserver } from '@/composables/elements/useResizeObserver'; +import type { UseResizeObserverOptions } from '@/composables/elements/useResizeObserver'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; + +export interface ElementSize { + width: number; + height: number; +} + +export interface UseElementSizeOptions extends UseResizeObserverOptions, ConfigurableWindow {} + +export interface UseElementSizeReturn { + width: ShallowRef; + height: ShallowRef; + stop: () => void; +} + +/** + * @name useElementSize + * @category Elements + * @description Reactive size of an element, backed by `ResizeObserver`. + * Measures synchronously on mount, handles SVG elements via `getBoundingClientRect`, + * and sums multiple box fragments (e.g. multi-column layouts). + * + * @param {MaybeComputedElementRef} target Element to measure (ref, getter, or component instance) + * @param {ElementSize} [initialSize={ width: 0, height: 0 }] Initial size, restored when the element detaches + * @param {UseElementSizeOptions} [options={}] Options forwarded to `ResizeObserver` (`box`, `window`) + * @returns {UseElementSizeReturn} Reactive `width`, `height`, and a `stop` handle + * + * @example + * const el = useTemplateRef('el'); + * const { width, height } = useElementSize(el); + * + * @example + * const { width, height, stop } = useElementSize(el, { width: 100, height: 100 }, { box: 'border-box' }); + * + * @since 0.0.15 + */ +export function useElementSize( + target: MaybeComputedElementRef, + initialSize: ElementSize = { width: 0, height: 0 }, + options: UseElementSizeOptions = {}, +): UseElementSizeReturn { + const { window = defaultWindow, box = 'content-box' } = options; + + const width = shallowRef(initialSize.width); + const height = shallowRef(initialSize.height); + + const isSVG = computed(() => unrefElement(target)?.namespaceURI?.includes('svg')); + + const { stop: stopObserver } = useResizeObserver(target, ([entry]) => { + if (!entry) + return; + + // SVG elements report unreliable box sizes in some browsers; measure the layout box instead. + if (window && isSVG.value) { + const el = unrefElement(target); + if (el) { + const rect = el.getBoundingClientRect(); + width.value = rect.width; + height.value = rect.height; + } + return; + } + + const boxSize = box === 'border-box' + ? entry.borderBoxSize + : box === 'content-box' + ? entry.contentBoxSize + : entry.devicePixelContentBoxSize; + + if (boxSize) { + // Normalise the cross-browser `ResizeObserverSize | ReadonlyArray` shape + // and sum fragments (e.g. multi-column layouts) in a single pass. + let nextWidth = 0; + let nextHeight = 0; + for (const size of toArray(boxSize as ResizeObserverSize | ResizeObserverSize[])) { + nextWidth += size.inlineSize; + nextHeight += size.blockSize; + } + width.value = nextWidth; + height.value = nextHeight; + } + else { + width.value = entry.contentRect.width; + height.value = entry.contentRect.height; + } + }, options); + + // Provide a measurement immediately on mount, before the first observer callback fires. + tryOnMounted(() => { + const el = unrefElement(target); + if (el) { + width.value = 'offsetWidth' in el ? (el as HTMLElement).offsetWidth : initialSize.width; + height.value = 'offsetHeight' in el ? (el as HTMLElement).offsetHeight : initialSize.height; + } + }); + + // Reset to the initial size when the element is attached/detached. + const stopWatch = watch( + () => unrefElement(target), + (el) => { + width.value = el ? initialSize.width : 0; + height.value = el ? initialSize.height : 0; + }, + ); + + const stop = (): void => { + stopObserver(); + stopWatch(); + }; + + return { width, height, stop }; +} diff --git a/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue b/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue new file mode 100644 index 0000000..f13ae2e --- /dev/null +++ b/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue @@ -0,0 +1,79 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useElementVisibility/index.test.ts b/vue/toolkit/src/composables/elements/useElementVisibility/index.test.ts new file mode 100644 index 0000000..30e3d0d --- /dev/null +++ b/vue/toolkit/src/composables/elements/useElementVisibility/index.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, ref } from 'vue'; +import type { UseElementVisibilityReturn } from '.'; +import { useElementVisibility } from '.'; + +let instances: StubIntersectionObserver[] = []; +let lastInit: IntersectionObserverInit | undefined; + +class StubIntersectionObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + takeRecords = vi.fn(); + cb: IntersectionObserverCallback; + init?: IntersectionObserverInit; + constructor(cb: IntersectionObserverCallback, init?: IntersectionObserverInit) { + this.cb = cb; + this.init = init; + lastInit = init; + instances.push(this); + } +} + +describe(useElementVisibility, () => { + beforeEach(() => { + instances = []; + lastInit = undefined; + vi.stubGlobal('IntersectionObserver', StubIntersectionObserver); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('is false initially and updates on intersection', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let isVisible: UseElementVisibilityReturn; + scope.run(() => { + isVisible = useElementVisibility(ref(el)); + }); + + expect(isVisible!.value).toBeFalsy(); + + instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(isVisible!.value).toBeTruthy(); + + instances[0]!.cb([{ isIntersecting: false, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(isVisible!.value).toBeFalsy(); + scope.stop(); + }); + + it('uses the most recent entry by time', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let isVisible: UseElementVisibilityReturn; + scope.run(() => { + isVisible = useElementVisibility(ref(el)); + }); + + instances[0]!.cb([ + { isIntersecting: false, time: 5 } as IntersectionObserverEntry, + { isIntersecting: true, time: 10 } as IntersectionObserverEntry, + ], {} as IntersectionObserver); + expect(isVisible!.value).toBeTruthy(); + scope.stop(); + }); + + it('respects initialValue', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let isVisible: UseElementVisibilityReturn; + scope.run(() => { + isVisible = useElementVisibility(ref(el), { initialValue: true }); + }); + + expect(isVisible!.value).toBeTruthy(); + scope.stop(); + }); + + it('returns a writable shallow ref (not readonly) by default', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let isVisible: UseElementVisibilityReturn; + scope.run(() => { + isVisible = useElementVisibility(ref(el)); + }); + + expect(isReadonly(isVisible!)).toBeFalsy(); + scope.stop(); + }); + + it('forwards rootMargin and threshold to the observer', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useElementVisibility(ref(el), { rootMargin: '10px', threshold: [0, 0.5, 1] })); + + expect(lastInit?.rootMargin).toBe('10px'); + expect(lastInit?.threshold).toEqual([0, 0.5, 1]); + scope.stop(); + }); + + it('stops observing after first visibility when once is true', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let isVisible: UseElementVisibilityReturn; + scope.run(() => { + isVisible = useElementVisibility(ref(el), { once: true }); + }); + + const observer = instances[0]!; + + // Not visible yet: should not disconnect. + observer.cb([{ isIntersecting: false, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(observer.disconnect).not.toHaveBeenCalled(); + expect(isVisible!.value).toBeFalsy(); + + // Becomes visible: stop() should disconnect the observer. + observer.cb([{ isIntersecting: true, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(isVisible!.value).toBeTruthy(); + expect(observer.disconnect).toHaveBeenCalled(); + scope.stop(); + }); + + it('exposes observer controls when controls is true', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let result: UseElementVisibilityReturn; + scope.run(() => { + result = useElementVisibility(ref(el), { controls: true }); + }); + + expect(result!).toHaveProperty('isVisible'); + expect(result!).toHaveProperty('stop'); + expect(result!).toHaveProperty('pause'); + expect(result!).toHaveProperty('resume'); + expect(result!).toHaveProperty('isSupported'); + expect(result!).toHaveProperty('isActive'); + + expect(result!.isVisible.value).toBeFalsy(); + instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(result!.isVisible.value).toBeTruthy(); + + result!.stop(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useElementVisibility/index.ts b/vue/toolkit/src/composables/elements/useElementVisibility/index.ts new file mode 100644 index 0000000..ee1e3ab --- /dev/null +++ b/vue/toolkit/src/composables/elements/useElementVisibility/index.ts @@ -0,0 +1,108 @@ +import { shallowRef } from 'vue'; +import type { ShallowRef } from 'vue'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { useIntersectionObserver } from '@/composables/elements/useIntersectionObserver'; +import type { UseIntersectionObserverOptions, UseIntersectionObserverReturn } from '@/composables/elements/useIntersectionObserver'; + +export interface UseElementVisibilityOptions extends UseIntersectionObserverOptions { + /** + * The initial visibility state, used before the observer reports its first entry. + * + * @default false + */ + initialValue?: boolean; + + /** + * Stop observing as soon as the element becomes visible for the first time. + * + * @default false + */ + once?: boolean; + + /** + * Expose the underlying observer controls (`pause`, `resume`, `stop`, ...) + * alongside the visibility ref instead of returning the ref directly. + * + * @default false + */ + controls?: Controls; +} + +export interface UseElementVisibilityReturnWithControls extends UseIntersectionObserverReturn { + /** + * Whether the element is currently visible within the root/viewport. + */ + isVisible: ShallowRef; +} + +export type UseElementVisibilityReturn + = Controls extends true + ? UseElementVisibilityReturnWithControls + : ShallowRef; + +/** + * @name useElementVisibility + * @category Elements + * @description Track whether an element is visible within the viewport (or a + * custom scroll root), backed by `IntersectionObserver`. + * + * @param {MaybeComputedElementRef} target Element to track + * @param {UseElementVisibilityOptions} [options={}] Options + * @returns {UseElementVisibilityReturn} Visibility ref, or `{ isVisible, ...controls }` when `controls` is `true` + * + * @example + * const isVisible = useElementVisibility(el); + * + * @example + * const { isVisible, stop } = useElementVisibility(el, { controls: true, once: true }); + * + * @since 0.0.15 + */ +export function useElementVisibility( + target: MaybeComputedElementRef, + options?: UseElementVisibilityOptions, +): UseElementVisibilityReturn; +export function useElementVisibility( + target: MaybeComputedElementRef, + options: UseElementVisibilityOptions, +): UseElementVisibilityReturn; +export function useElementVisibility( + target: MaybeComputedElementRef, + options: UseElementVisibilityOptions = {}, +): UseElementVisibilityReturn { + const { + initialValue = false, + once = false, + controls = false, + ...observerOptions + } = options; + + const isVisible = shallowRef(initialValue); + + const observer = useIntersectionObserver(target, (entries) => { + // Use the most recent entry to reflect the latest state. + let latest = isVisible.value; + let latestTime = 0; + + for (const entry of entries) { + if (entry.time >= latestTime) { + latestTime = entry.time; + latest = entry.isIntersecting; + } + } + + isVisible.value = latest; + + if (once && latest) + observer.stop(); + }, observerOptions); + + if (controls) { + return { + ...observer, + isVisible, + }; + } + + return isVisible; +} diff --git a/vue/toolkit/src/composables/elements/useFocusGuard/demo.vue b/vue/toolkit/src/composables/elements/useFocusGuard/demo.vue new file mode 100644 index 0000000..c27546e --- /dev/null +++ b/vue/toolkit/src/composables/elements/useFocusGuard/demo.vue @@ -0,0 +1,85 @@ + + + diff --git a/web/vue/src/composables/browser/useFocusGuard/index.test.ts b/vue/toolkit/src/composables/elements/useFocusGuard/index.test.ts similarity index 96% rename from web/vue/src/composables/browser/useFocusGuard/index.test.ts rename to vue/toolkit/src/composables/elements/useFocusGuard/index.test.ts index be5e308..1215525 100644 --- a/web/vue/src/composables/browser/useFocusGuard/index.test.ts +++ b/vue/toolkit/src/composables/elements/useFocusGuard/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeEach, afterEach, expect } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mount } from '@vue/test-utils'; import { defineComponent, nextTick } from 'vue'; import { useFocusGuard } from '.'; @@ -10,7 +10,7 @@ const setupFocusGuard = (namespace?: string) => { useFocusGuard(namespace); }, template: '
', - }) + }), ); }; diff --git a/web/vue/src/composables/browser/useFocusGuard/index.ts b/vue/toolkit/src/composables/elements/useFocusGuard/index.ts similarity index 95% rename from web/vue/src/composables/browser/useFocusGuard/index.ts rename to vue/toolkit/src/composables/elements/useFocusGuard/index.ts index db2e83c..7d9145b 100644 --- a/web/vue/src/composables/browser/useFocusGuard/index.ts +++ b/vue/toolkit/src/composables/elements/useFocusGuard/index.ts @@ -6,18 +6,18 @@ let counter = 0; /** * @name useFocusGuard - * @category Browser + * @category Elements * @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 * @returns {void} - * + * * @example * useFocusGuard(); - * + * * @example * useFocusGuard('my-namespace'); - * + * * @since 0.0.2 */ export function useFocusGuard(namespace?: string) { @@ -31,7 +31,7 @@ export function useFocusGuard(namespace?: string) { const removeGuard = () => { if (counter <= 1) manager.removeGuard(); - + counter = Math.max(0, counter - 1); }; diff --git a/vue/toolkit/src/composables/elements/useIntersectionObserver/demo.vue b/vue/toolkit/src/composables/elements/useIntersectionObserver/demo.vue new file mode 100644 index 0000000..87925ca --- /dev/null +++ b/vue/toolkit/src/composables/elements/useIntersectionObserver/demo.vue @@ -0,0 +1,114 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useIntersectionObserver/index.test.ts b/vue/toolkit/src/composables/elements/useIntersectionObserver/index.test.ts new file mode 100644 index 0000000..3289cd6 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useIntersectionObserver/index.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useIntersectionObserver } from '.'; + +interface StubInstance { + cb: IntersectionObserverCallback; + options?: IntersectionObserverInit; + observe: ReturnType; + disconnect: ReturnType; +} + +let instances: StubInstance[] = []; + +class StubIntersectionObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + takeRecords = vi.fn(); + cb: IntersectionObserverCallback; + options?: IntersectionObserverInit; + constructor(cb: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.cb = cb; + this.options = options; + instances.push(this); + } +} + +describe(useIntersectionObserver, () => { + beforeEach(() => { + instances = []; + vi.stubGlobal('IntersectionObserver', StubIntersectionObserver); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('observes the target immediately', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(ref(el), vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el); + scope.stop(); + }); + + it('does not observe when immediate is false', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { immediate: false })); + + expect(instances).toHaveLength(0); + scope.stop(); + }); + + it('pause disconnects and resume re-observes', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let controls: ReturnType; + scope.run(() => { + controls = useIntersectionObserver(ref(el), vi.fn()); + }); + + controls!.pause(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + expect(controls!.isActive.value).toBeFalsy(); + + controls!.resume(); + await nextTick(); + expect(controls!.isActive.value).toBeTruthy(); + expect(instances).toHaveLength(2); + scope.stop(); + }); + + it('stop disconnects and marks inactive', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let controls: ReturnType; + scope.run(() => { + controls = useIntersectionObserver(ref(el), vi.fn()); + }); + + controls!.stop(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + expect(controls!.isActive.value).toBeFalsy(); + scope.stop(); + }); + + it('invokes the callback with entries', () => { + const el = document.createElement('div'); + const callback = vi.fn(); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(ref(el), callback)); + + const entry = { isIntersecting: true, time: 1 } as IntersectionObserverEntry; + instances[0]!.cb([entry], instances[0] as unknown as IntersectionObserver); + expect(callback).toHaveBeenCalled(); + scope.stop(); + }); + + it('observes an array of targets', () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useIntersectionObserver([ref(a), ref(b)], vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(a); + expect(instances[0]!.observe).toHaveBeenCalledWith(b); + scope.stop(); + }); + + it('tracks a reactive target ref of an array', async () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + const list = ref([a]); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(list, vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledTimes(1); + + list.value = [a, b]; + await nextTick(); + + // recreated with both elements + expect(instances).toHaveLength(2); + expect(instances[1]!.observe).toHaveBeenCalledWith(a); + expect(instances[1]!.observe).toHaveBeenCalledWith(b); + scope.stop(); + }); + + it('tracks a getter target', async () => { + const a = document.createElement('div'); + const enabled = ref(false); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(() => (enabled.value ? a : null), vi.fn())); + + // null target -> no observer + expect(instances).toHaveLength(0); + + enabled.value = true; + await nextTick(); + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(a); + scope.stop(); + }); + + it('passes rootMargin and threshold to the observer', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin: '10px', threshold: [0, 0.5, 1] })); + + expect(instances[0]!.options?.rootMargin).toBe('10px'); + expect(instances[0]!.options?.threshold).toEqual([0, 0.5, 1]); + scope.stop(); + }); + + it('reacts to a reactive rootMargin', async () => { + const el = document.createElement('div'); + const rootMargin = ref('0px'); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin })); + + expect(instances[0]!.options?.rootMargin).toBe('0px'); + + rootMargin.value = '20px'; + await nextTick(); + + expect(instances).toHaveLength(2); + expect(instances[1]!.options?.rootMargin).toBe('20px'); + scope.stop(); + }); + + it('reacts to a reactive threshold', async () => { + const el = document.createElement('div'); + const threshold = ref(0); + const scope = effectScope(); + scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { threshold })); + + expect(instances[0]!.options?.threshold).toBe(0); + + threshold.value = 0.75; + await nextTick(); + + expect(instances).toHaveLength(2); + expect(instances[1]!.options?.threshold).toBe(0.75); + scope.stop(); + }); + + it('reports unsupported and never constructs an observer', () => { + // jsdom has no native IntersectionObserver; remove the stub so the + // feature detection `'IntersectionObserver' in window` reports false. + vi.unstubAllGlobals(); + delete (globalThis as Record).IntersectionObserver; + const el = document.createElement('div'); + const scope = effectScope(); + let controls: ReturnType; + scope.run(() => { + controls = useIntersectionObserver(ref(el), vi.fn()); + }); + + expect(controls!.isSupported.value).toBeFalsy(); + // stop should be a safe no-op + expect(() => controls!.stop()).not.toThrow(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useIntersectionObserver/index.ts b/vue/toolkit/src/composables/elements/useIntersectionObserver/index.ts new file mode 100644 index 0000000..ef157c8 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useIntersectionObserver/index.ts @@ -0,0 +1,150 @@ +import { computed, readonly, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { noop, toArray } from '@robonen/stdlib'; +import type { ConfigurableWindow } from '@/types'; +import { defaultWindow } from '@/types'; +import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement'; +import { unrefElement } from '@/composables/component/unrefElement'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseIntersectionObserverOptions extends ConfigurableWindow { + /** + * The element or document used as the viewport for checking visibility + */ + root?: MaybeComputedElementRef | Document; + + /** + * Margin around the root. Reactive — pass a ref or getter to update it. + * + * @default '0px' + */ + rootMargin?: MaybeRefOrGetter; + + /** + * Threshold(s) at which to trigger the callback. Reactive — pass a ref or + * getter to update it. + * + * @default 0 + */ + threshold?: MaybeRefOrGetter; + + /** + * Start observing immediately + * + * @default true + */ + immediate?: boolean; +} + +export interface UseIntersectionObserverReturn { + isSupported: Readonly>; + isActive: Readonly>; + pause: () => void; + resume: () => void; + stop: () => void; +} + +/** + * @name useIntersectionObserver + * @category Elements + * @description Detect when an element enters or leaves the viewport via + * `IntersectionObserver`. Accepts a single target, an array of targets, or a + * ref/getter resolving to either, plus reactive `rootMargin` and `threshold`. + * + * @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter} target Element(s) to observe + * @param {IntersectionObserverCallback} callback Invoked with the observer entries + * @param {UseIntersectionObserverOptions} [options={}] Options + * @returns {UseIntersectionObserverReturn} Observer controls + * + * @example + * useIntersectionObserver(el, ([{ isIntersecting }]) => { + * visible.value = isIntersecting; + * }); + * + * @since 0.0.15 + */ +export function useIntersectionObserver( + target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter, + callback: IntersectionObserverCallback, + options: UseIntersectionObserverOptions = {}, +): UseIntersectionObserverReturn { + const { + root, + rootMargin = '0px', + threshold = 0, + window = defaultWindow, + immediate = true, + } = options; + + const isSupported = useSupported(() => window && 'IntersectionObserver' in window); + + const targets = computed(() => { + const value = toValue(target) as MaybeElement | MaybeElement[]; + return toArray(value as MaybeElement) + .map(el => unrefElement(el)) + .filter((el): el is Element => Boolean(el)); + }); + + const isActive = ref(immediate); + + let cleanup = noop; + + const stopWatch = isSupported.value + ? watch( + () => [ + targets.value, + unrefElement(root as MaybeComputedElementRef), + toValue(rootMargin), + toValue(threshold), + isActive.value, + ] as const, + ([els, rootEl, margin, thresh, active]) => { + cleanup(); + + if (!active || !els.length) + return; + + const observer = new IntersectionObserver(callback, { + root: (rootEl as Element | null) ?? (root as Document | undefined), + rootMargin: margin, + threshold: thresh, + }); + + for (const el of els) + observer.observe(el); + + cleanup = () => { + observer.disconnect(); + cleanup = noop; + }; + }, + { immediate: true, flush: 'post' }, + ) + : noop; + + const resume = (): void => { + isActive.value = true; + }; + + const pause = (): void => { + cleanup(); + isActive.value = false; + }; + + const stop = (): void => { + cleanup(); + stopWatch(); + isActive.value = false; + }; + + tryOnScopeDispose(stop); + + return { + isSupported, + isActive: readonly(isActive), + pause, + resume, + stop, + }; +} diff --git a/vue/toolkit/src/composables/elements/useMutationObserver/demo.vue b/vue/toolkit/src/composables/elements/useMutationObserver/demo.vue new file mode 100644 index 0000000..460d995 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useMutationObserver/demo.vue @@ -0,0 +1,144 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useMutationObserver/index.test.ts b/vue/toolkit/src/composables/elements/useMutationObserver/index.test.ts new file mode 100644 index 0000000..cc6e21e --- /dev/null +++ b/vue/toolkit/src/composables/elements/useMutationObserver/index.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useMutationObserver } from '.'; + +let instances: Array<{ cb: MutationCallback; observe: ReturnType; disconnect: ReturnType; takeRecords: ReturnType }> = []; + +class StubMutationObserver { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + cb: MutationCallback; + constructor(cb: MutationCallback) { + this.cb = cb; + instances.push(this); + } +} + +describe(useMutationObserver, () => { + beforeEach(() => { + instances = []; + vi.stubGlobal('MutationObserver', StubMutationObserver); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('observes the target with the given options', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useMutationObserver(ref(el), vi.fn(), { attributes: true })); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el, { attributes: true }); + scope.stop(); + }); + + it('does not leak immediate/window into observer options', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useMutationObserver(ref(el), vi.fn(), { childList: true, immediate: true })); + + expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true }); + scope.stop(); + }); + + it('disconnects on stop', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let stop: () => void; + scope.run(() => { + stop = useMutationObserver(ref(el), vi.fn()).stop; + }); + + stop!(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + scope.stop(); + }); + + it('forwards records to the callback', () => { + const el = document.createElement('div'); + const callback = vi.fn(); + const scope = effectScope(); + scope.run(() => useMutationObserver(ref(el), callback)); + + const records = [{ type: 'attributes' } as MutationRecord]; + instances[0]!.cb(records, instances[0] as unknown as MutationObserver); + expect(callback).toHaveBeenCalledWith(records, expect.anything()); + scope.stop(); + }); + + it('observes an array of targets with a single observer', () => { + const a = document.createElement('div'); + const b = document.createElement('span'); + const scope = effectScope(); + scope.run(() => useMutationObserver([ref(a), b], vi.fn(), { childList: true })); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledTimes(2); + expect(instances[0]!.observe).toHaveBeenCalledWith(a, { childList: true }); + expect(instances[0]!.observe).toHaveBeenCalledWith(b, { childList: true }); + scope.stop(); + }); + + it('accepts a getter returning an array of targets', () => { + const a = document.createElement('div'); + const b = document.createElement('span'); + const scope = effectScope(); + scope.run(() => useMutationObserver(() => [a, b], vi.fn(), { childList: true })); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledTimes(2); + scope.stop(); + }); + + it('deduplicates repeated targets', () => { + const a = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useMutationObserver([a, a, ref(a)], vi.fn(), { childList: true })); + + expect(instances[0]!.observe).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it('skips nullish targets', () => { + const scope = effectScope(); + scope.run(() => useMutationObserver([ref(null), ref(undefined)], vi.fn(), { childList: true })); + + expect(instances).toHaveLength(0); + scope.stop(); + }); + + it('re-observes when a reactive target changes', async () => { + const el = document.createElement('div'); + const target = ref(null); + const scope = effectScope(); + scope.run(() => useMutationObserver(target, vi.fn(), { childList: true })); + + await nextTick(); + expect(instances).toHaveLength(0); + + target.value = el; + await nextTick(); + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true }); + scope.stop(); + }); + + it('does not observe when immediate is false, then resumes', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api: ReturnType; + scope.run(() => { + api = useMutationObserver(ref(el), vi.fn(), { attributes: true, immediate: false }); + }); + + await nextTick(); + expect(instances).toHaveLength(0); + expect(api!.isActive.value).toBeFalsy(); + + api!.resume(); + await nextTick(); + expect(instances).toHaveLength(1); + expect(api!.isActive.value).toBeTruthy(); + scope.stop(); + }); + + it('pause disconnects and flips isActive, resume re-observes', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api: ReturnType; + scope.run(() => { + api = useMutationObserver(ref(el), vi.fn(), { attributes: true }); + }); + + expect(instances).toHaveLength(1); + + api!.pause(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + expect(api!.isActive.value).toBeFalsy(); + + api!.resume(); + await nextTick(); + expect(instances).toHaveLength(2); + scope.stop(); + }); + + it('takeRecords proxies to the active observer and returns undefined when inactive', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api: ReturnType; + scope.run(() => { + api = useMutationObserver(ref(el), vi.fn()); + }); + + expect(api!.takeRecords()).toEqual([]); + expect(instances[0]!.takeRecords).toHaveBeenCalled(); + + api!.stop(); + expect(api!.takeRecords()).toBeUndefined(); + scope.stop(); + }); + + it('reports isSupported false when MutationObserver is missing', () => { + const scope = effectScope(); + let api: ReturnType; + const el = document.createElement('div'); + scope.run(() => { + api = useMutationObserver(ref(el), vi.fn(), { window: { foo: 1 } as unknown as Window & typeof globalThis }); + }); + + expect(api!.isSupported.value).toBeFalsy(); + expect(instances).toHaveLength(0); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useMutationObserver/index.ts b/vue/toolkit/src/composables/elements/useMutationObserver/index.ts new file mode 100644 index 0000000..1298450 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useMutationObserver/index.ts @@ -0,0 +1,140 @@ +import { computed, readonly, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { toArray } from '@robonen/stdlib'; +import type { ConfigurableWindow } from '@/types'; +import { defaultWindow } from '@/types'; +import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement'; +import { unrefElement } from '@/composables/component/unrefElement'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseMutationObserverOptions extends MutationObserverInit, ConfigurableWindow { + /** + * Start observing immediately once a target is available + * + * @default true + */ + immediate?: boolean; +} + +export interface UseMutationObserverReturn { + isSupported: Readonly>; + /** + * Whether the observer is currently active (not paused or stopped) + */ + isActive: Readonly>; + /** + * Temporarily disconnect the observer without tearing down the watcher. + * Re-observe with `resume`. + */ + pause: () => void; + /** + * Re-attach the observer to the current target(s) after a `pause`. + */ + resume: () => void; + /** + * Permanently stop observing and dispose the watcher. + */ + stop: () => void; + /** + * Synchronously take and clear the observer's record queue + */ + takeRecords: () => MutationRecord[] | undefined; +} + +/** + * @name useMutationObserver + * @category Elements + * @description Watch for changes to the DOM tree via `MutationObserver`. + * Accepts a single target, an array of targets, or a getter returning either. + * + * @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter} target Element(s) to observe + * @param {MutationCallback} callback Invoked with the mutation records + * @param {UseMutationObserverOptions} [options={}] Observer options (childList, attributes, …) + * @returns {UseMutationObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, `stop`, and `takeRecords` + * + * @example + * useMutationObserver(el, (records) => { + * console.log(records); + * }, { attributes: true }); + * + * @example + * const { pause, resume } = useMutationObserver([elA, elB], onMutate, { childList: true }); + * + * @since 0.0.15 + */ +export function useMutationObserver( + target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter, + callback: MutationCallback, + options: UseMutationObserverOptions = {}, +): UseMutationObserverReturn { + const { window = defaultWindow, immediate = true, ...observerOptions } = options; + + const isSupported = useSupported(() => window && 'MutationObserver' in window); + + let observer: MutationObserver | undefined; + + const isActive = ref(immediate); + + const targets = computed(() => { + const value = toArray(toValue(target)); + const set = new Set(); + + for (const item of value) { + const el = unrefElement(item as MaybeComputedElementRef); + if (el) + set.add(el); + } + + return set; + }); + + const cleanup = () => { + if (observer) { + observer.disconnect(); + observer = undefined; + } + }; + + const takeRecords = () => observer?.takeRecords(); + + const stopWatch = watch( + () => [targets.value, isActive.value] as const, + ([els, active]) => { + cleanup(); + + if (!active || !isSupported.value || !window || !els.size) + return; + + observer = new MutationObserver(callback); + for (const el of els) + observer.observe(el, observerOptions); + }, + { immediate: true, flush: 'post' }, + ); + + const resume = () => { + isActive.value = true; + }; + + const pause = () => { + cleanup(); + isActive.value = false; + }; + + const stop = () => { + cleanup(); + stopWatch(); + }; + + tryOnScopeDispose(stop); + + return { + isSupported, + isActive: readonly(isActive), + pause, + resume, + stop, + takeRecords, + }; +} diff --git a/vue/toolkit/src/composables/elements/useParentElement/demo.vue b/vue/toolkit/src/composables/elements/useParentElement/demo.vue new file mode 100644 index 0000000..05ca7e6 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useParentElement/demo.vue @@ -0,0 +1,98 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useParentElement/index.test.ts b/vue/toolkit/src/composables/elements/useParentElement/index.test.ts new file mode 100644 index 0000000..6ca9432 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useParentElement/index.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { defineComponent, nextTick, shallowRef } from 'vue'; +import { mount } from '@vue/test-utils'; +import type { UseParentElementReturn } from '.'; +import { useParentElement } from '.'; + +describe(useParentElement, () => { + it('resolves to the parent of an explicit element ref', async () => { + const child = document.createElement('span'); + const parent = document.createElement('div'); + parent.appendChild(child); + + const elRef = shallowRef(child); + const result = useParentElement(elRef); + + await nextTick(); + + expect(result.value).toBe(parent); + }); + + it('reacts when the target element ref changes', async () => { + const parentA = document.createElement('div'); + const childA = document.createElement('span'); + parentA.appendChild(childA); + + const parentB = document.createElement('section'); + const childB = document.createElement('p'); + parentB.appendChild(childB); + + const elRef = shallowRef(childA); + const result = useParentElement(elRef); + + await nextTick(); + expect(result.value).toBe(parentA); + + elRef.value = childB; + await nextTick(); + + expect(result.value).toBe(parentB); + }); + + it('accepts a getter as the target', async () => { + const child = document.createElement('span'); + const parent = document.createElement('article'); + parent.appendChild(child); + + const result = useParentElement(() => child); + + await nextTick(); + + expect(result.value).toBe(parent); + }); + + it('resolves to null/undefined when the element has no parent', async () => { + const orphan = document.createElement('span'); + const result = useParentElement(shallowRef(orphan)); + + await nextTick(); + + expect(result.value).toBeFalsy(); + }); + + it('resolves to undefined when the target ref is null (SSR / unmounted path)', async () => { + const elRef = shallowRef(null); + const result = useParentElement(elRef); + + await nextTick(); + + expect(result.value).toBeUndefined(); + }); + + it('updates to undefined when the target becomes null', async () => { + const child = document.createElement('span'); + const parent = document.createElement('div'); + parent.appendChild(child); + + const elRef = shallowRef(child); + const result = useParentElement(elRef); + + await nextTick(); + expect(result.value).toBe(parent); + + elRef.value = null; + await nextTick(); + + expect(result.value).toBeUndefined(); + }); + + it('defaults to the current instance root element parent', async () => { + let result!: UseParentElementReturn; + + const Child = defineComponent({ + setup() { + result = useParentElement(); + return {}; + }, + template: `leaf`, + }); + + const Parent = defineComponent({ + components: { Child }, + template: `
`, + }); + + const wrapper = mount(Parent); + await nextTick(); + + expect(result.value).toBe(wrapper.find('.wrapper').element); + }); + + it('does not throw outside a component instance (SSR-safe default)', () => { + let result!: UseParentElementReturn; + + expect(() => { + result = useParentElement(); + }).not.toThrow(); + + expect(result).toBeDefined(); + expect(result.value).toBeUndefined(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useParentElement/index.ts b/vue/toolkit/src/composables/elements/useParentElement/index.ts new file mode 100644 index 0000000..af90bce --- /dev/null +++ b/vue/toolkit/src/composables/elements/useParentElement/index.ts @@ -0,0 +1,49 @@ +import { shallowRef, watch } from 'vue'; +import type { MaybeRefOrGetter, ShallowRef } from 'vue'; +import { unrefElement } from '@/composables/component/unrefElement'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { useCurrentElement } from '@/composables/component/useCurrentElement'; + +export type UseParentElementReturn + = Readonly>; + +/** + * @name useParentElement + * @category Elements + * @description Reactive `parentElement` of a given element (or the current + * component instance's root element when no target is supplied). Resolves the + * target through `unrefElement`, so it accepts plain elements, template refs, + * component instances, getters and computed refs. A single `immediate` watcher + * tracks the resolved target and re-reads its parent only when the element + * itself changes — no extra lifecycle hooks or always-on observers. SSR-safe: + * stays `undefined` until the target is resolved on the client. + * + * @param {MaybeComputedElementRef | MaybeRefOrGetter} [element] Target element/ref/getter; defaults to the current instance's root element + * @returns {UseParentElementReturn} A read-only shallow ref of the resolved parent element + * + * @example + * // Parent of the current component's root element + * const parent = useParentElement(); + * + * @example + * // Parent of a specific template ref + * const el = useTemplateRef('el'); + * const parent = useParentElement(el); + * + * @since 0.0.15 + */ +export function useParentElement( + element: MaybeComputedElementRef | MaybeRefOrGetter = useCurrentElement(), +): UseParentElementReturn { + const parentElement = shallowRef(); + + watch( + () => unrefElement(element as MaybeComputedElementRef), + (el) => { + parentElement.value = (el as Element | null | undefined)?.parentElement; + }, + { immediate: true, flush: 'post' }, + ); + + return parentElement; +} diff --git a/vue/toolkit/src/composables/elements/useResizeObserver/demo.vue b/vue/toolkit/src/composables/elements/useResizeObserver/demo.vue new file mode 100644 index 0000000..025dabd --- /dev/null +++ b/vue/toolkit/src/composables/elements/useResizeObserver/demo.vue @@ -0,0 +1,80 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useResizeObserver/index.test.ts b/vue/toolkit/src/composables/elements/useResizeObserver/index.test.ts new file mode 100644 index 0000000..deb30d9 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useResizeObserver/index.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useResizeObserver } from '.'; + +let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType; disconnect: ReturnType }> = []; + +class StubResizeObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + instances.push(this); + } +} + +describe(useResizeObserver, () => { + beforeEach(() => { + instances = []; + vi.stubGlobal('ResizeObserver', StubResizeObserver); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('observes the target element', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useResizeObserver(ref(el), vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined); + scope.stop(); + }); + + it('passes the box option through to observe', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useResizeObserver(ref(el), vi.fn(), { box: 'border-box' })); + + expect(instances[0]!.observe).toHaveBeenCalledWith(el, { box: 'border-box' }); + scope.stop(); + }); + + it('observes an array of targets with a single observer', () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useResizeObserver([ref(a), ref(b)], vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined); + expect(instances[0]!.observe).toHaveBeenCalledWith(b, undefined); + scope.stop(); + }); + + it('supports a getter target', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useResizeObserver(() => el, vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined); + scope.stop(); + }); + + it('disconnects on stop', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let stop: () => void; + scope.run(() => { + stop = useResizeObserver(ref(el), vi.fn()).stop; + }); + + stop!(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + scope.stop(); + }); + + it('invokes the callback with entries', () => { + const el = document.createElement('div'); + const callback = vi.fn(); + const scope = effectScope(); + scope.run(() => useResizeObserver(ref(el), callback)); + + const entry = { contentRect: { width: 10, height: 20 } } as ResizeObserverEntry; + instances[0]!.cb([entry], instances[0] as unknown as ResizeObserver); + expect(callback).toHaveBeenCalledWith([entry], expect.anything()); + scope.stop(); + }); + + it('re-observes when the target ref changes', async () => { + const a = document.createElement('div'); + const b = document.createElement('div'); + const target = ref(a); + const scope = effectScope(); + scope.run(() => useResizeObserver(target, vi.fn())); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined); + + target.value = b; + await nextTick(); + + expect(instances[0]!.disconnect).toHaveBeenCalled(); + expect(instances).toHaveLength(2); + expect(instances[1]!.observe).toHaveBeenCalledWith(b, undefined); + scope.stop(); + }); + + it('does not create an observer for a null target', () => { + const target = ref(null); + const scope = effectScope(); + scope.run(() => useResizeObserver(target, vi.fn())); + + expect(instances).toHaveLength(0); + scope.stop(); + }); + + it('starts observing when a null target is later assigned', async () => { + const el = document.createElement('div'); + const target = ref(null); + const scope = effectScope(); + scope.run(() => useResizeObserver(target, vi.fn())); + + expect(instances).toHaveLength(0); + + target.value = el; + await nextTick(); + + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined); + scope.stop(); + }); + + it('does not observe when immediate is false until resumed', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let controls!: ReturnType; + scope.run(() => { + controls = useResizeObserver(ref(el), vi.fn(), { immediate: false }); + }); + + expect(controls.isActive.value).toBeFalsy(); + expect(instances).toHaveLength(0); + + controls.resume(); + await nextTick(); + + expect(controls.isActive.value).toBeTruthy(); + expect(instances).toHaveLength(1); + expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined); + scope.stop(); + }); + + it('pause disconnects and flips isActive, resume re-observes', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let controls!: ReturnType; + scope.run(() => { + controls = useResizeObserver(ref(el), vi.fn()); + }); + + expect(controls.isActive.value).toBeTruthy(); + expect(instances).toHaveLength(1); + + controls.pause(); + expect(controls.isActive.value).toBeFalsy(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + + controls.resume(); + await nextTick(); + + expect(controls.isActive.value).toBeTruthy(); + expect(instances).toHaveLength(2); + expect(instances[1]!.observe).toHaveBeenCalledWith(el, undefined); + scope.stop(); + }); + + it('cleans up when the scope is disposed', () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => useResizeObserver(ref(el), vi.fn())); + + expect(instances).toHaveLength(1); + scope.stop(); + expect(instances[0]!.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useResizeObserver/index.ts b/vue/toolkit/src/composables/elements/useResizeObserver/index.ts new file mode 100644 index 0000000..4680f17 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useResizeObserver/index.ts @@ -0,0 +1,149 @@ +import { computed, readonly, ref, watch } from 'vue'; +import type { Ref } from 'vue'; +import { toArray } from '@robonen/stdlib'; +import type { ConfigurableWindow } from '@/types'; +import { defaultWindow } from '@/types'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { unrefElement } from '@/composables/component/unrefElement'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseResizeObserverOptions extends ConfigurableWindow { + /** + * The box model to observe + * + * @default 'content-box' + */ + box?: ResizeObserverBoxOptions; + + /** + * Start observing immediately once the target is resolved + * + * @default true + */ + immediate?: boolean; +} + +export type ResizeObserverCallback = ( + entries: readonly ResizeObserverEntry[], + observer: ResizeObserver, +) => void; + +export interface UseResizeObserverReturn { + /** + * Whether `ResizeObserver` is supported in the current environment + */ + isSupported: Readonly>; + + /** + * Whether the observer is currently active + */ + isActive: Readonly>; + + /** + * Temporarily stop observing (disconnects the observer) while keeping the + * target watcher alive, so observing can be resumed later + */ + pause: () => void; + + /** + * Resume observing after a `pause` + */ + resume: () => void; + + /** + * Permanently stop observing and tear down the target watcher + */ + stop: () => void; +} + +/** + * @name useResizeObserver + * @category Elements + * @description Reports changes to the dimensions of an element via `ResizeObserver`. + * Accepts a single target or an array of (reactive) targets. The observer is + * recreated only when the resolved elements change, and can be paused/resumed. + * + * @param {MaybeComputedElementRef | MaybeComputedElementRef[]} target Element(s) to observe + * @param {ResizeObserverCallback} callback Invoked with the observer entries + * @param {UseResizeObserverOptions} [options={}] Options + * @returns {UseResizeObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, and `stop` + * + * @example + * useResizeObserver(el, ([entry]) => { + * console.log(entry.contentRect.width); + * }); + * + * @example + * const { pause, resume } = useResizeObserver([el1, el2], (entries) => { + * // react to multiple targets + * }, { box: 'border-box' }); + * + * @since 0.0.15 + */ +export function useResizeObserver( + target: MaybeComputedElementRef | MaybeComputedElementRef[], + callback: ResizeObserverCallback, + options: UseResizeObserverOptions = {}, +): UseResizeObserverReturn { + const { window = defaultWindow, box, immediate = true } = options; + + const isSupported = useSupported(() => window && 'ResizeObserver' in window); + + // Cache the observer options object so it is not rebuilt on every observe call + const observerOptions: ResizeObserverOptions | undefined = box ? { box } : undefined; + + const isActive = ref(immediate); + + let observer: ResizeObserver | undefined; + + const targets = computed(() => { + return toArray(target).map(el => unrefElement(el)).filter((el): el is Element => Boolean(el)); + }); + + const cleanup = () => { + if (observer) { + observer.disconnect(); + observer = undefined; + } + }; + + const stopWatch = watch( + () => [targets.value, isActive.value] as const, + ([els, active]) => { + cleanup(); + + if (!active || !isSupported.value || !window || !els.length) + return; + + observer = new ResizeObserver(callback); + for (const el of els) + observer.observe(el, observerOptions); + }, + { immediate: true, flush: 'post' }, + ); + + const resume = () => { + isActive.value = true; + }; + + const pause = () => { + cleanup(); + isActive.value = false; + }; + + const stop = () => { + cleanup(); + stopWatch(); + }; + + tryOnScopeDispose(stop); + + return { + isSupported, + isActive: readonly(isActive), + pause, + resume, + stop, + }; +} diff --git a/vue/toolkit/src/composables/elements/useWindowFocus/demo.vue b/vue/toolkit/src/composables/elements/useWindowFocus/demo.vue new file mode 100644 index 0000000..8424b7f --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowFocus/demo.vue @@ -0,0 +1,53 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useWindowFocus/index.test.ts b/vue/toolkit/src/composables/elements/useWindowFocus/index.test.ts new file mode 100644 index 0000000..3a4af5c --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowFocus/index.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { effectScope, isReadonly } from 'vue'; +import { useWindowFocus } from '.'; + +interface FakeWindow { + document: { hasFocus: () => boolean }; + addEventListener: Window['addEventListener']; + removeEventListener: Window['removeEventListener']; + dispatchEvent: Window['dispatchEvent']; +} + +// Build a minimal window-like object whose event plumbing is driven by a real +// EventTarget, while `document.hasFocus()` is controllable for initial state. +function createFakeWindow(initialFocus: boolean): FakeWindow { + const target = new EventTarget(); + + return { + document: { hasFocus: () => initialFocus }, + addEventListener: target.addEventListener.bind(target) as Window['addEventListener'], + removeEventListener: target.removeEventListener.bind(target) as Window['removeEventListener'], + dispatchEvent: target.dispatchEvent.bind(target) as Window['dispatchEvent'], + }; +} + +describe(useWindowFocus, () => { + it('initialises from document.hasFocus() (focused)', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeTruthy(); + + scope.stop(); + }); + + it('initialises from document.hasFocus() (blurred)', () => { + const fakeWindow = createFakeWindow(false); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('becomes false on blur', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeTruthy(); + + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('becomes true on focus', () => { + const fakeWindow = createFakeWindow(false); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeFalsy(); + + fakeWindow.dispatchEvent(new Event('focus')); + expect(focused!.value).toBeTruthy(); + + scope.stop(); + }); + + it('tracks repeated focus/blur transitions', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + fakeWindow.dispatchEvent(new Event('focus')); + expect(focused!.value).toBeTruthy(); + + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('removes listeners when the scope is disposed', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + scope.stop(); + + // after disposal, events must no longer mutate the ref + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeTruthy(); + }); + + it('returns a writable shallow ref (not readonly)', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(isReadonly(focused!)).toBeFalsy(); + + scope.stop(); + }); + + it('returns false and does not throw when window is unavailable (SSR)', () => { + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus({ window: undefined }); + }); + + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('uses the real jsdom window by default', () => { + const scope = effectScope(); + let focused: ReturnType; + scope.run(() => { + focused = useWindowFocus(); + }); + + // initial value mirrors document.hasFocus() + expect(focused!.value).toBe(document.hasFocus()); + + globalThis.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + globalThis.dispatchEvent(new Event('focus')); + expect(focused!.value).toBeTruthy(); + + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useWindowFocus/index.ts b/vue/toolkit/src/composables/elements/useWindowFocus/index.ts new file mode 100644 index 0000000..cbf160d --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowFocus/index.ts @@ -0,0 +1,43 @@ +import { shallowRef } from 'vue'; +import type { ShallowRef } from 'vue'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; + +export interface UseWindowFocusOptions extends ConfigurableWindow {} + +export type UseWindowFocusReturn = ShallowRef; + +/** + * @name useWindowFocus + * @category Elements + * @description Reactively track whether the window is focused via `focus`/`blur` events. + * + * @param {UseWindowFocusOptions} [options={}] Options + * @returns {UseWindowFocusReturn} A shallow ref that is `true` while the window has focus + * + * @example + * const focused = useWindowFocus(); + * + * @since 0.0.15 + */ +export function useWindowFocus(options: UseWindowFocusOptions = {}): UseWindowFocusReturn { + const { window = defaultWindow } = options; + + if (!window) + return shallowRef(false); + + const focused = shallowRef(window.document.hasFocus()); + + const listenerOptions = { passive: true } as const; + + useEventListener(window, 'blur', () => { + focused.value = false; + }, listenerOptions); + + useEventListener(window, 'focus', () => { + focused.value = true; + }, listenerOptions); + + return focused; +} diff --git a/vue/toolkit/src/composables/elements/useWindowScroll/demo.vue b/vue/toolkit/src/composables/elements/useWindowScroll/demo.vue new file mode 100644 index 0000000..7f022dd --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowScroll/demo.vue @@ -0,0 +1,112 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useWindowScroll/index.test.ts b/vue/toolkit/src/composables/elements/useWindowScroll/index.test.ts new file mode 100644 index 0000000..c0e5b66 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowScroll/index.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useWindowScroll } from '.'; + +function setScroll(x: number, y: number): void { + (globalThis as any).scrollX = x; + (globalThis as any).scrollY = y; +} + +describe(useWindowScroll, () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'scrollX', { value: 0, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'scrollY', { value: 0, configurable: true, writable: true }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('reads the initial scroll position', () => { + setScroll(15, 25); + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + expect(result!.x.value).toBe(15); + expect(result!.y.value).toBe(25); + scope.stop(); + }); + + it('updates on scroll', async () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(30, 60); + globalThis.dispatchEvent(new Event('scroll')); + await nextTick(); + + expect(result!.x.value).toBe(30); + expect(result!.y.value).toBe(60); + scope.stop(); + }); + + it('scrolls the window when writing to x/y', () => { + const scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + result!.x.value = 100; + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 })); + result!.y.value = 200; + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 })); + scope.stop(); + }); + + it('passes the configured behavior when writing to x/y', () => { + const scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll({ behavior: 'smooth' }); + }); + + result!.x.value = 50; + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 50, behavior: 'smooth' })); + scope.stop(); + }); + + it('exposes isScrolling that toggles on scroll and resets after idle', async () => { + vi.useFakeTimers(); + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll({ idle: 50 }); + }); + + expect(result!.isScrolling.value).toBeFalsy(); + + setScroll(10, 10); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.isScrolling.value).toBeTruthy(); + + vi.advanceTimersByTime(60); + await nextTick(); + expect(result!.isScrolling.value).toBeFalsy(); + + scope.stop(); + vi.useRealTimers(); + }); + + it('calls onScroll and onStop callbacks', async () => { + vi.useFakeTimers(); + const onScroll = vi.fn(); + const onStop = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useWindowScroll({ idle: 50, onScroll, onStop }); + }); + + setScroll(5, 5); + globalThis.dispatchEvent(new Event('scroll')); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onStop).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(60); + await nextTick(); + expect(onStop).toHaveBeenCalledTimes(1); + + scope.stop(); + vi.useRealTimers(); + }); + + it('tracks scroll directions', () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(40, 80); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.directions.right).toBeTruthy(); + expect(result!.directions.bottom).toBeTruthy(); + expect(result!.directions.left).toBeFalsy(); + expect(result!.directions.top).toBeFalsy(); + + setScroll(10, 20); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.directions.left).toBeTruthy(); + expect(result!.directions.top).toBeTruthy(); + expect(result!.directions.right).toBeFalsy(); + expect(result!.directions.bottom).toBeFalsy(); + + scope.stop(); + }); + + it('reports arrivedState at the top/left edges initially', () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + expect(result!.arrivedState.top).toBeTruthy(); + expect(result!.arrivedState.left).toBeTruthy(); + scope.stop(); + }); + + it('clears arrivedState.top once scrolled down', () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(0, 100); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.arrivedState.top).toBeFalsy(); + scope.stop(); + }); + + it('honors the top offset for arrivedState', () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll({ offset: { top: 30 } }); + }); + + setScroll(0, 20); + globalThis.dispatchEvent(new Event('scroll')); + // Within the 30px offset, still considered "arrived at top". + expect(result!.arrivedState.top).toBeTruthy(); + + setScroll(0, 40); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.arrivedState.top).toBeFalsy(); + scope.stop(); + }); + + it('measure() recomputes state without a scroll event', () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(0, 70); + // No event dispatched; values are stale until measure(). + expect(result!.y.value).toBe(0); + result!.measure(); + expect(result!.y.value).toBe(70); + scope.stop(); + }); + + it('is SSR-safe when window is undefined', () => { + const scope = effectScope(); + let result: ReturnType; + scope.run(() => { + result = useWindowScroll({ window: undefined }); + }); + + expect(result!.x.value).toBe(0); + expect(result!.y.value).toBe(0); + expect(result!.isScrolling.value).toBeFalsy(); + // Writing should be a no-op (no throw). + expect(() => { + result!.x.value = 10; + }).not.toThrow(); + scope.stop(); + }); + + it('throttles the scroll handler when throttle is set', async () => { + vi.useFakeTimers(); + const onScroll = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useWindowScroll({ throttle: 100, onScroll }); + }); + + setScroll(1, 1); + globalThis.dispatchEvent(new Event('scroll')); + setScroll(2, 2); + globalThis.dispatchEvent(new Event('scroll')); + setScroll(3, 3); + globalThis.dispatchEvent(new Event('scroll')); + + // Trailing-only throttle: collapses the burst into a single deferred call. + vi.advanceTimersByTime(120); + await nextTick(); + expect(onScroll).toHaveBeenCalledTimes(1); + + scope.stop(); + vi.useRealTimers(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useWindowScroll/index.ts b/vue/toolkit/src/composables/elements/useWindowScroll/index.ts new file mode 100644 index 0000000..05e3b5d --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowScroll/index.ts @@ -0,0 +1,272 @@ +import { computed, reactive, shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, Reactive, ShallowRef, WritableComputedRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { useDebounceFn } from '@/composables/reactivity/useDebounceFn'; +import { useThrottleFn } from '@/composables/reactivity/useThrottleFn'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; + +/** + * `scrollTop`/`scrollLeft` are sub-pixel (fractional) numbers, while + * `scrollHeight`/`scrollWidth` and `clientHeight`/`clientWidth` are rounded + * integers. We therefore allow a 1px tolerance when deciding whether an edge + * has been reached. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled + */ +const ARRIVED_STATE_THRESHOLD_PIXELS = 1; + +export interface UseWindowScrollOffset { + left?: number; + right?: number; + top?: number; + bottom?: number; +} + +export interface UseWindowScrollEdgeState { + left: boolean; + right: boolean; + top: boolean; + bottom: boolean; +} + +export interface UseWindowScrollOptions extends ConfigurableWindow { + /** + * Throttle time (ms) for the scroll handler. Disabled by default. + * + * @default 0 + */ + throttle?: number; + + /** + * Delay (ms) after the last scroll event before `isScrolling` flips back to + * `false`. When `throttle` is set the effective idle window becomes + * `throttle + idle`. + * + * @default 200 + */ + idle?: number; + + /** + * Offset the `arrivedState` edges by a number of pixels, e.g. to treat the + * page as "arrived at bottom" slightly before the true bottom. + */ + offset?: UseWindowScrollOffset; + + /** + * Invoked on every (throttled) scroll event. + */ + onScroll?: (event: Event) => void; + + /** + * Invoked once scrolling stops (after the idle window elapses). + */ + onStop?: (event: Event) => void; + + /** + * Listener options for the scroll event. + * + * @default { capture: false, passive: true } + */ + eventListenerOptions?: boolean | AddEventListenerOptions; + + /** + * Scroll behavior applied when writing to `x`/`y`. `'auto'` jumps instantly, + * `'smooth'` animates. Accepts a ref or getter for reactivity. + * + * @default 'auto' + */ + behavior?: MaybeRefOrGetter; +} + +export interface UseWindowScrollReturn { + /** + * Reactive horizontal scroll position. Writing to it scrolls the window. + */ + x: WritableComputedRef; + + /** + * Reactive vertical scroll position. Writing to it scrolls the window. + */ + y: WritableComputedRef; + + /** + * Whether the window is currently being scrolled. + */ + isScrolling: ShallowRef; + + /** + * Whether each edge of the document has been reached. + */ + arrivedState: Reactive; + + /** + * The direction(s) the window is currently scrolling towards. + */ + directions: Reactive; + + /** + * Force a re-measurement of `arrivedState`/`directions`. + */ + measure: () => void; +} + +/** + * @name useWindowScroll + * @category Elements + * @description Reactive window scroll position with arrived/direction tracking. Writing to `x`/`y` scrolls the window. + * + * @param {UseWindowScrollOptions} [options={}] Options + * @returns {UseWindowScrollReturn} Reactive `x`, `y`, `isScrolling`, `arrivedState`, `directions` and a `measure()` helper + * + * @example + * const { x, y, isScrolling, arrivedState, directions } = useWindowScroll(); + * + * @since 0.0.15 + */ +export function useWindowScroll(options: UseWindowScrollOptions = {}): UseWindowScrollReturn { + const { + window = defaultWindow, + throttle = 0, + idle = 200, + onStop = noop, + onScroll = noop, + offset = {}, + eventListenerOptions = { capture: false, passive: true }, + behavior = 'auto', + } = options; + + const internalX = shallowRef(0); + const internalY = shallowRef(0); + + // We use computed getters/setters so that writing `x`/`y` triggers a real + // `scrollTo()` while the internal refs are updated from the scroll event + // without re-triggering a scroll. + const x = computed({ + get: () => internalX.value, + set: value => scrollTo(value, undefined), + }); + + const y = computed({ + get: () => internalY.value, + set: value => scrollTo(undefined, value), + }); + + function scrollTo(_x: number | undefined, _y: number | undefined): void { + if (!window) + return; + + window.scrollTo({ + left: _x ?? internalX.value, + top: _y ?? internalY.value, + behavior: toValue(behavior), + }); + + if (_x !== null && _x !== undefined) + internalX.value = _x; + if (_y !== null && _y !== undefined) + internalY.value = _y; + } + + const isScrolling = shallowRef(false); + + const arrivedState = reactive({ + left: true, + right: false, + top: true, + bottom: false, + }); + + const directions = reactive({ + left: false, + right: false, + top: false, + bottom: false, + }); + + function setArrivedState(): void { + if (!window) + return; + + const el = window.document.documentElement; + const { direction } = window.getComputedStyle(el); + const directionMultiplier = direction === 'rtl' ? -1 : 1; + + const scrollLeft = window.scrollX; + directions.left = scrollLeft < internalX.value; + directions.right = scrollLeft > internalX.value; + + arrivedState.left = Math.abs(scrollLeft * directionMultiplier) <= (offset.left ?? 0); + arrivedState.right = Math.abs(scrollLeft * directionMultiplier) + + el.clientWidth >= el.scrollWidth + - (offset.right ?? 0) + - ARRIVED_STATE_THRESHOLD_PIXELS; + + internalX.value = scrollLeft; + + const scrollTop = window.scrollY; + directions.top = scrollTop < internalY.value; + directions.bottom = scrollTop > internalY.value; + + arrivedState.top = Math.abs(scrollTop) <= (offset.top ?? 0); + arrivedState.bottom = Math.abs(scrollTop) + + el.clientHeight >= el.scrollHeight + - (offset.bottom ?? 0) + - ARRIVED_STATE_THRESHOLD_PIXELS; + + internalY.value = scrollTop; + } + + function onScrollEnd(event: Event): void { + // Dedupe in case the native `scrollend` event is supported. + if (!isScrolling.value) + return; + + isScrolling.value = false; + directions.left = false; + directions.right = false; + directions.top = false; + directions.bottom = false; + onStop(event); + } + + const onScrollEndDebounced = useDebounceFn(onScrollEnd, throttle + idle); + + function onScrollHandler(event: Event): void { + if (!window) + return; + + setArrivedState(); + + isScrolling.value = true; + onScrollEndDebounced(event); + onScroll(event); + } + + useEventListener( + window, + 'scroll', + throttle ? useThrottleFn(onScrollHandler, throttle, true, false) : onScrollHandler, + eventListenerOptions, + ); + + useEventListener( + window, + 'scrollend', + onScrollEnd, + eventListenerOptions, + ); + + tryOnMounted(setArrivedState); + + return { + x, + y, + isScrolling, + arrivedState, + directions, + measure: setArrivedState, + }; +} diff --git a/vue/toolkit/src/composables/elements/useWindowSize/demo.vue b/vue/toolkit/src/composables/elements/useWindowSize/demo.vue new file mode 100644 index 0000000..47574ec --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowSize/demo.vue @@ -0,0 +1,70 @@ + + + diff --git a/vue/toolkit/src/composables/elements/useWindowSize/index.test.ts b/vue/toolkit/src/composables/elements/useWindowSize/index.test.ts new file mode 100644 index 0000000..9d60cf7 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowSize/index.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useWindowSize } from '.'; + +describe(useWindowSize, () => { + beforeEach(() => { + vi.stubGlobal('matchMedia', undefined); + window.innerWidth = 1024; + window.innerHeight = 768; + }); + afterEach(() => vi.unstubAllGlobals()); + + it('reads the current window size', () => { + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useWindowSize({ listenOrientation: false }); + }); + + expect(size!.width.value).toBe(1024); + expect(size!.height.value).toBe(768); + scope.stop(); + }); + + it('updates on resize', async () => { + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useWindowSize({ listenOrientation: false }); + }); + + window.innerWidth = 500; + window.innerHeight = 400; + globalThis.dispatchEvent(new Event('resize')); + await nextTick(); + + expect(size!.width.value).toBe(500); + expect(size!.height.value).toBe(400); + scope.stop(); + }); + + it('uses documentElement client size when includeScrollbar is false', () => { + Object.defineProperty(globalThis.document.documentElement, 'clientWidth', { + configurable: true, + value: 1000, + }); + Object.defineProperty(globalThis.document.documentElement, 'clientHeight', { + configurable: true, + value: 700, + }); + + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, includeScrollbar: false }); + }); + + expect(size!.width.value).toBe(1000); + expect(size!.height.value).toBe(700); + scope.stop(); + }); + + it('reads outer window size for type "outer"', () => { + window.outerWidth = 1440; + window.outerHeight = 900; + + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, type: 'outer' }); + }); + + expect(size!.width.value).toBe(1440); + expect(size!.height.value).toBe(900); + scope.stop(); + }); + + it('reads scaled visual viewport size for type "visual"', async () => { + const visualViewport = { + width: 800, + height: 600, + scale: 1.5, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + Object.defineProperty(globalThis, 'visualViewport', { + configurable: true, + value: visualViewport, + }); + + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, type: 'visual' }); + }); + await nextTick(); + + expect(size!.width.value).toBe(1200); + expect(size!.height.value).toBe(900); + expect(visualViewport.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function), expect.objectContaining({ passive: true })); + scope.stop(); + + Object.defineProperty(globalThis, 'visualViewport', { configurable: true, value: undefined }); + }); + + it('falls back to inner size for type "visual" without visualViewport', () => { + Object.defineProperty(globalThis, 'visualViewport', { configurable: true, value: undefined }); + + const scope = effectScope(); + let size: ReturnType; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, type: 'visual' }); + }); + + expect(size!.width.value).toBe(1024); + expect(size!.height.value).toBe(768); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/elements/useWindowSize/index.ts b/vue/toolkit/src/composables/elements/useWindowSize/index.ts new file mode 100644 index 0000000..84cd062 --- /dev/null +++ b/vue/toolkit/src/composables/elements/useWindowSize/index.ts @@ -0,0 +1,135 @@ +import { shallowRef, watch } from 'vue'; +import type { ShallowRef } from 'vue'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { useMediaQuery } from '@/composables/browser/useMediaQuery'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; + +/** + * Which window dimensions to track. + * + * - `'inner'` — `window.innerWidth/innerHeight` (or `documentElement.clientWidth/clientHeight` + * when `includeScrollbar` is `false`). The viewport size. + * - `'outer'` — `window.outerWidth/outerHeight`. The whole browser window, including chrome. + * - `'visual'` — `window.visualViewport` size, accounting for pinch-zoom scale. Useful on + * mobile where the visual viewport differs from the layout viewport. + */ +export type WindowSizeType = 'inner' | 'outer' | 'visual'; + +export interface UseWindowSizeOptions extends ConfigurableWindow { + /** + * The initial width, used before the window is available (e.g. during SSR). + * + * @default Number.POSITIVE_INFINITY + */ + initialWidth?: number; + + /** + * The initial height, used before the window is available (e.g. during SSR). + * + * @default Number.POSITIVE_INFINITY + */ + initialHeight?: number; + + /** + * Listen to orientation changes via a `(orientation: portrait)` media query. + * + * @default true + */ + listenOrientation?: boolean; + + /** + * Use `window.innerWidth/innerHeight` (includes scrollbar) instead of + * `documentElement.clientWidth/clientHeight`. Only affects the `'inner'` type. + * + * @default true + */ + includeScrollbar?: boolean; + + /** + * Which window dimensions to track. + * + * @default 'inner' + */ + type?: WindowSizeType; +} + +export interface UseWindowSizeReturn { + width: ShallowRef; + height: ShallowRef; +} + +/** + * @name useWindowSize + * @category Elements + * @description Reactive window size. Tracks the inner viewport, the outer window, or the + * visual viewport (pinch-zoom aware), and reacts to resize and orientation changes. + * + * @param {UseWindowSizeOptions} [options={}] Options + * @returns {UseWindowSizeReturn} Reactive `width` and `height` + * + * @example + * const { width, height } = useWindowSize(); + * + * @example + * // Track the pinch-zoom aware visual viewport on mobile + * const { width, height } = useWindowSize({ type: 'visual' }); + * + * @since 0.0.15 + */ +export function useWindowSize(options: UseWindowSizeOptions = {}): UseWindowSizeReturn { + const { + window = defaultWindow, + initialWidth = Number.POSITIVE_INFINITY, + initialHeight = Number.POSITIVE_INFINITY, + listenOrientation = true, + includeScrollbar = true, + type = 'inner', + } = options; + + const width = shallowRef(initialWidth); + const height = shallowRef(initialHeight); + + const update = (): void => { + if (!window) + return; + + if (type === 'outer') { + width.value = window.outerWidth; + height.value = window.outerHeight; + } + else if (type === 'visual' && window.visualViewport) { + const { width: visualWidth, height: visualHeight, scale } = window.visualViewport; + width.value = Math.round(visualWidth * scale); + height.value = Math.round(visualHeight * scale); + } + else if (includeScrollbar) { + width.value = window.innerWidth; + height.value = window.innerHeight; + } + else { + width.value = window.document.documentElement.clientWidth; + height.value = window.document.documentElement.clientHeight; + } + }; + + update(); + tryOnMounted(update); + + const listenerOptions = { passive: true } as const; + + useEventListener('resize', update, listenerOptions); + + // Reactive getter target: auto-binds when `visualViewport` becomes available and + // is a no-op otherwise (SSR / unsupported), without recreating listeners. + if (type === 'visual') + useEventListener(() => window?.visualViewport, 'resize', update, listenerOptions); + + if (listenOrientation) { + const orientation = useMediaQuery('(orientation: portrait)', { window }); + watch(orientation, update); + } + + return { width, height }; +} diff --git a/vue/toolkit/src/composables/forms/index.ts b/vue/toolkit/src/composables/forms/index.ts new file mode 100644 index 0000000..3c9d55a --- /dev/null +++ b/vue/toolkit/src/composables/forms/index.ts @@ -0,0 +1,4 @@ +export * from './useField'; +export * from './useFieldArray'; +export * from './useForm'; +export * from './useFormContext'; diff --git a/vue/toolkit/src/composables/forms/useField/demo.vue b/vue/toolkit/src/composables/forms/useField/demo.vue new file mode 100644 index 0000000..7b8c44c --- /dev/null +++ b/vue/toolkit/src/composables/forms/useField/demo.vue @@ -0,0 +1,102 @@ + + + diff --git a/vue/toolkit/src/composables/forms/useField/index.test.ts b/vue/toolkit/src/composables/forms/useField/index.test.ts new file mode 100644 index 0000000..11e7247 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useField/index.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { mount } from '@vue/test-utils'; +import { useForm } from '../useForm'; +import type { UseFieldOptions, UseFieldReturn, UseFormReturn } from '../useForm'; +import { useField } from '.'; + +function mountField( + path: string, + fieldOptions: Omit, 'form'> = {}, + initialValues: Record = {}, +) { + let form!: UseFormReturn; + let field!: UseFieldReturn; + + mount(defineComponent({ + setup() { + form = useForm({ initialValues }); + field = useField(path, { ...fieldOptions, form }); + return () => h('div'); + }, + })); + + return { form, field }; +} + +function mountStandalone(path: string, fieldOptions: UseFieldOptions) { + let field!: UseFieldReturn; + + mount(defineComponent({ + setup() { + field = useField(path, fieldOptions); + return () => h('div'); + }, + })); + + return field; +} + +describe(useField, () => { + describe('bound to a form', () => { + it('reads and writes the form value through value', () => { + const { form, field } = mountField('email', {}, { email: 'init' }); + + expect(field.value.value).toBe('init'); + field.value.value = 'changed'; + expect(form.values.email).toBe('changed'); + }); + + it('reflects the form errors for its path', async () => { + const { field } = mountField('email', { + validate: value => (value.includes('@') ? true : 'Invalid'), + }, { email: '' }); + + const result = await field.validate(); + expect(result.valid).toBeFalsy(); + expect(field.errorMessage.value).toBe('Invalid'); + + field.value.value = 'a@b.com'; + const ok = await field.validate(); + expect(ok.valid).toBeTruthy(); + expect(field.errors.value).toEqual([]); + }); + + it('validates with a per-field schema', async () => { + const { field } = mountField('name', { + schema: { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: any) => (value ? { value } : { issues: [{ message: 'Required' }] }), + }, + }, + }, { name: '' }); + + expect((await field.validate()).valid).toBeFalsy(); + expect(field.errorMessage.value).toBe('Required'); + }); + + it('marks the field touched on blur', () => { + const { field } = mountField('email', {}, { email: '' }); + + expect(field.meta.touched.value).toBeFalsy(); + field.handleBlur(); + expect(field.meta.touched.value).toBeTruthy(); + }); + + it('tracks dirty state', () => { + const { field } = mountField('email', {}, { email: 'a' }); + + expect(field.meta.dirty.value).toBeFalsy(); + field.value.value = 'b'; + expect(field.meta.dirty.value).toBeTruthy(); + }); + + it('exposes attrs with name and aria-invalid', async () => { + const { form, field } = mountField('email', {}, { email: '' }); + + expect(field.attrs.value.name).toBe('email'); + expect(field.attrs.value['aria-invalid']).toBeUndefined(); + + form.setFieldError('email', 'bad'); + expect(field.attrs.value['aria-invalid']).toBeTruthy(); + }); + }); + + describe('standalone (no form)', () => { + it('holds its own value and validates locally', async () => { + const field = mountStandalone('search', { + initialValue: '', + validate: value => (value.length >= 3 ? true : 'Too short'), + }); + + expect(field.value.value).toBe(''); + field.handleChange('ab'); + const result = await field.validate(); + expect(result.valid).toBeFalsy(); + expect(field.errorMessage.value).toBe('Too short'); + + field.handleChange('abc'); + expect((await field.validate()).valid).toBeTruthy(); + }); + + it('resets to its initial value', () => { + const field = mountStandalone('x', { initialValue: 'start' }); + + field.value.value = 'changed'; + expect(field.meta.dirty.value).toBeTruthy(); + field.reset(); + expect(field.value.value).toBe('start'); + expect(field.meta.dirty.value).toBeFalsy(); + }); + }); +}); diff --git a/vue/toolkit/src/composables/forms/useField/index.ts b/vue/toolkit/src/composables/forms/useField/index.ts new file mode 100644 index 0000000..6ebb6eb --- /dev/null +++ b/vue/toolkit/src/composables/forms/useField/index.ts @@ -0,0 +1,236 @@ +import { computed, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { cloneFnDefault } from '@/composables/reactivity/useCloned'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; +import { isEqual } from '@robonen/stdlib'; +import { injectFormContext } from '../useForm/context'; +import { normalizeFieldResult, runStandardSchema } from '../useForm/validation'; +import type { + FieldBindingProps, + FieldMeta, + FieldValidationResultDetail, + FieldValidator, + UseFieldOptions, + UseFieldReturn, + ValidationTrigger, +} from '../useForm'; + +/** + * @name useField + * @category Forms + * @description Bind a single field by path. When rendered under a {@link useForm} + * (or given an explicit `form`), it reads/writes that form's state; otherwise it + * runs standalone with its own value, errors, and validation. Returns a writable + * `value`, reactive errors/meta, blur/change handlers, and `attrs` to spread. + * + * @param {MaybeRefOrGetter} path The dotted field path (reactive allowed) + * @param {UseFieldOptions} [options={}] Validators, schema, trigger, explicit form, or standalone `initialValue` + * @returns {UseFieldReturn} The field's reactive value, errors, meta, and handlers + * + * @example + * // Within a useForm() component + * const { value, errorMessage, attrs } = useField('email', { + * validate: (v) => v.includes('@') || 'Invalid email', + * }); + * // + * + * @example + * // Standalone (no form ancestor) + * const { value, errors } = useField('search', { initialValue: '', schema }); + * + * @since 0.0.16 + */ +export function useField( + path: MaybeRefOrGetter, + options: UseFieldOptions = {}, +): UseFieldReturn { + const form = options.form ?? injectFormContext(); + const resolvePath = (): string => toValue(path); + + // Wrap a per-field Standard Schema as a function validator (reuse the adapter). + const schemaValidator: ((value: T) => Promise) | undefined = options.schema + ? async (value): Promise => { + const run = await runStandardSchema(options.schema!, value); + return run.valid ? [] : Object.values(run.errors).flat(); + } + : undefined; + + // Decide whether a given event should trigger validation, honoring a + // field-level `validateOn` override or deferring to the form's gate. + function fieldShould(event: 'value' | 'blur'): boolean { + const override: ValidationTrigger | undefined = options.validateOn; + if (override) { + if (override === 'manual' || override === 'submit') + return false; + return override === 'value' || event === 'blur'; + } + + return form ? form._shouldValidate(event) : true; + } + + if (form) { + // ---- bound mode ----------------------------------------------------- + if (options.validate) + form._registerValidator(resolvePath(), options.validate as FieldValidator); + if (schemaValidator) + form._registerValidator(resolvePath(), schemaValidator as FieldValidator); + + // Re-register field validators when a reactive path changes. + watch(resolvePath, (next, prev) => { + if (next === prev) + return; + if (options.validate) { + form._unregisterValidator(prev, options.validate as FieldValidator); + form._registerValidator(next, options.validate as FieldValidator); + } + if (schemaValidator) { + form._unregisterValidator(prev, schemaValidator as FieldValidator); + form._registerValidator(next, schemaValidator as FieldValidator); + } + }); + + tryOnScopeDispose(() => { + if (options.validate) + form._unregisterValidator(resolvePath(), options.validate as FieldValidator); + if (schemaValidator) + form._unregisterValidator(resolvePath(), schemaValidator as FieldValidator); + }); + + const value = computed({ + get: () => form.getFieldValue(resolvePath() as never) as T, + set: (next) => { + form.setFieldValue(resolvePath() as never, next as never, { shouldValidate: false }); + if (fieldShould('value')) + void form.validateField(resolvePath() as never); + }, + }); + + const errors = computed(() => form.getErrors(resolvePath() as never)); + const errorMessage = computed(() => errors.value[0]); + + const meta: FieldMeta = { + dirty: computed(() => form.isFieldDirty(resolvePath() as never)), + touched: computed(() => form.isFieldTouched(resolvePath() as never)), + valid: computed(() => errors.value.length === 0), + }; + + const setValue = (next: T): void => { + value.value = next; + }; + + const handleChange = (next: T, shouldValidate = fieldShould('value')): void => { + form.setFieldValue(resolvePath() as never, next as never, { shouldValidate }); + }; + + const handleBlur = (): void => { + form.setFieldTouched(resolvePath() as never, true); + if (fieldShould('blur')) + void form.validateField(resolvePath() as never); + }; + + const handleInput = (event: Event): void => { + const target = event.target as HTMLInputElement; + const next = (target.type === 'checkbox' ? target.checked : target.value) as unknown as T; + handleChange(next); + }; + + const attrs = computed(() => ({ + name: resolvePath(), + onBlur: handleBlur, + 'aria-invalid': errors.value.length > 0 ? true : undefined, + })); + + return { + value, + errors, + errorMessage, + meta, + handleBlur, + handleChange, + handleInput, + setValue, + setTouched: (touched = true) => form.setFieldTouched(resolvePath() as never, touched), + setErrors: message => form.setFieldError(resolvePath() as never, message), + validate: () => form.validateField(resolvePath() as never), + reset: () => form.resetField(resolvePath() as never), + attrs, + }; + } + + // ---- standalone mode -------------------------------------------------- + const localValue = ref(options.initialValue) as Ref; + const localErrors = ref([]); + const localTouched = ref(false); + const initialSnapshot = cloneFnDefault(options.initialValue) as T; + + async function runLocal(): Promise { + const messages: string[] = []; + + if (schemaValidator) + messages.push(...await schemaValidator(localValue.value)); + if (options.validate) + messages.push(...normalizeFieldResult(await options.validate(localValue.value, {} as never))); + + localErrors.value = messages; + return { valid: messages.length === 0, errors: messages }; + } + + const errors = computed(() => localErrors.value); + const errorMessage = computed(() => errors.value[0]); + + const meta: FieldMeta = { + dirty: computed(() => !isEqual(localValue.value, initialSnapshot)), + touched: computed(() => localTouched.value), + valid: computed(() => errors.value.length === 0), + }; + + const handleChange = (next: T, shouldValidate = fieldShould('value')): void => { + localValue.value = next; + if (shouldValidate) + void runLocal(); + }; + + const handleBlur = (): void => { + localTouched.value = true; + if (fieldShould('blur')) + void runLocal(); + }; + + const handleInput = (event: Event): void => { + const target = event.target as HTMLInputElement; + const next = (target.type === 'checkbox' ? target.checked : target.value) as unknown as T; + handleChange(next); + }; + + const attrs = computed(() => ({ + name: resolvePath(), + onBlur: handleBlur, + 'aria-invalid': errors.value.length > 0 ? true : undefined, + })); + + return { + value: localValue, + errors, + errorMessage, + meta, + handleBlur, + handleChange, + handleInput, + setValue: (next) => { + localValue.value = next; + }, + setTouched: (touched = true) => { + localTouched.value = touched; + }, + setErrors: (message) => { + localErrors.value = message === null ? [] : Array.isArray(message) ? message : [message]; + }, + validate: runLocal, + reset: () => { + localValue.value = cloneFnDefault(initialSnapshot); + localErrors.value = []; + localTouched.value = false; + }, + attrs, + }; +} diff --git a/vue/toolkit/src/composables/forms/useFieldArray/demo.vue b/vue/toolkit/src/composables/forms/useFieldArray/demo.vue new file mode 100644 index 0000000..2b84ecf --- /dev/null +++ b/vue/toolkit/src/composables/forms/useFieldArray/demo.vue @@ -0,0 +1,122 @@ + + + diff --git a/vue/toolkit/src/composables/forms/useFieldArray/index.test.ts b/vue/toolkit/src/composables/forms/useFieldArray/index.test.ts new file mode 100644 index 0000000..5b5ef33 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useFieldArray/index.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { mount } from '@vue/test-utils'; +import { useForm } from '../useForm'; +import type { UseFieldArrayReturn, UseFormReturn } from '../useForm'; +import { useFieldArray } from '.'; + +interface User { name: string } + +function mountArray(initial: User[]) { + let form!: UseFormReturn<{ users: User[] }>; + let arr!: UseFieldArrayReturn; + + mount(defineComponent({ + setup() { + form = useForm<{ users: User[] }>({ initialValues: { users: initial } }); + arr = useFieldArray('users', { form }); + return () => h('div'); + }, + })); + + return { form, arr }; +} + +const names = (form: UseFormReturn<{ users: User[] }>): string[] => form.values.users.map(u => u.name); + +describe(useFieldArray, () => { + it('exposes entries with stable keys', () => { + const { arr } = mountArray([{ name: 'a' }, { name: 'b' }]); + + expect(arr.fields.value).toHaveLength(2); + expect(arr.fields.value[0]!.isFirst).toBeTruthy(); + expect(arr.fields.value[1]!.isLast).toBeTruthy(); + expect(arr.fields.value[0]!.key).not.toBe(arr.fields.value[1]!.key); + }); + + it('push and prepend add items', () => { + const { form, arr } = mountArray([{ name: 'a' }]); + + arr.push({ name: 'b' }); + expect(names(form)).toEqual(['a', 'b']); + + arr.prepend({ name: 'z' }); + expect(names(form)).toEqual(['z', 'a', 'b']); + }); + + it('insert, remove and update', () => { + const { form, arr } = mountArray([{ name: 'a' }, { name: 'c' }]); + + arr.insert(1, { name: 'b' }); + expect(names(form)).toEqual(['a', 'b', 'c']); + + arr.remove(0); + expect(names(form)).toEqual(['b', 'c']); + + arr.update(1, { name: 'C' }); + expect(names(form)).toEqual(['b', 'C']); + }); + + it('move and swap reorder items and keys together', () => { + const { form, arr } = mountArray([{ name: 'a' }, { name: 'b' }, { name: 'c' }]); + const keys = arr.fields.value.map(f => f.key); + + arr.move(0, 2); + expect(names(form)).toEqual(['b', 'c', 'a']); + expect(arr.fields.value.map(f => f.key)).toEqual([keys[1], keys[2], keys[0]]); + + arr.swap(0, 2); + expect(names(form)).toEqual(['a', 'c', 'b']); + }); + + it('replace swaps the entire array', () => { + const { form, arr } = mountArray([{ name: 'a' }]); + + arr.replace([{ name: 'x' }, { name: 'y' }]); + expect(names(form)).toEqual(['x', 'y']); + expect(arr.fields.value).toHaveLength(2); + }); + + it('remaps errors when an item is removed', () => { + const { form, arr } = mountArray([{ name: 'a' }, { name: 'b' }]); + + form.setFieldError('users.1.name', 'Bad'); + expect(form.getError('users.1.name')).toBe('Bad'); + + arr.remove(0); + + // The second item shifted into index 0 — its error follows. + expect(form.getError('users.0.name')).toBe('Bad'); + expect(form.getErrors('users.1.name')).toEqual([]); + }); + + it('drops the error of a removed item', () => { + const { form, arr } = mountArray([{ name: 'a' }, { name: 'b' }]); + + form.setFieldError('users.0.name', 'Gone'); + arr.remove(0); + + expect(form.getErrors('users.0.name')).toEqual([]); + expect(form.getError('users.0.name')).toBeUndefined(); + }); + + it('lets an entry value edit the underlying slot', () => { + const { form, arr } = mountArray([{ name: 'a' }]); + + arr.fields.value[0]!.value.value = { name: 'edited' }; + expect(form.values.users[0]!.name).toBe('edited'); + }); +}); diff --git a/vue/toolkit/src/composables/forms/useFieldArray/index.ts b/vue/toolkit/src/composables/forms/useFieldArray/index.ts new file mode 100644 index 0000000..dfc7819 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useFieldArray/index.ts @@ -0,0 +1,146 @@ +import { computed, ref, toValue, watch } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; +import { insert as insertAt, move as moveItem, remove as removeAt, swap as swapItems } from '@robonen/stdlib'; +import { injectFormContext } from '../useForm/context'; +import type { + FieldArrayEntry, + UseFieldArrayOptions, + UseFieldArrayReturn, +} from '../useForm'; + +/** + * @name useFieldArray + * @category Forms + * @description Manage a dynamic array field within a {@link useForm}. Exposes a + * reactive `fields` list with **stable keys** (preserved across reorders, so + * `v-for :key` keeps DOM/state intact) plus immutable `push`/`prepend`/`insert`/ + * `remove`/`move`/`swap`/`replace`/`update` operations that also re-key the + * matching errors and touched state. + * + * @param {MaybeRefOrGetter} path The dotted path of the array field + * @param {UseFieldArrayOptions} [options={}] Optionally an explicit `form` + * @returns {UseFieldArrayReturn} The reactive entries and mutation helpers + * + * @example + * const form = useForm({ initialValues: { users: [{ name: '' }] } }); + * const { fields, push, remove } = useFieldArray('users'); + * //
+ * // + * //
+ * // + * + * @since 0.0.16 + */ +export function useFieldArray( + path: MaybeRefOrGetter, + options: UseFieldArrayOptions = {}, +): UseFieldArrayReturn { + const form = options.form ?? injectFormContext(); + + if (!form) + throw new Error('[useFieldArray] must be used within a useForm() provider or given an explicit `form`.'); + + const resolvePath = (): string => toValue(path); + + function currentArray(): T[] { + return (form!.getFieldValue(resolvePath() as never) as T[] | undefined) ?? []; + } + + // Stable keys parallel to the values array — one per item, preserved on reorder. + let keyCounter = 0; + const keys = ref(currentArray().map(() => keyCounter++)); + + // Reconcile keys if the array length changes from outside our own ops. + watch(() => currentArray().length, (length) => { + const current = keys.value; + if (length > current.length) { + const next = current.slice(); + while (next.length < length) + next.push(keyCounter++); + keys.value = next; + } + else if (length < current.length) { + keys.value = current.slice(0, length); + } + }); + + function indexRef(index: number): Ref { + return computed({ + get: () => (form!.getFieldValue(`${resolvePath()}.${index}` as never) as T), + set: value => form!.setFieldValue(`${resolvePath()}.${index}` as never, value as never, { shouldValidate: false }), + }) as unknown as Ref; + } + + const fields = computed>>(() => { + const length = currentArray().length; + const keyList = keys.value; + + return Array.from({ length }, (_unused, index) => ({ + key: keyList[index] ?? index, + value: indexRef(index), + isFirst: index === 0, + isLast: index === length - 1, + })); + }); + + function writeArray(nextValues: T[], nextKeys: number[]): void { + form!.setFieldValue(resolvePath() as never, nextValues as never, { shouldValidate: false }); + keys.value = nextKeys; + } + + function push(value: T): void { + writeArray([...currentArray(), value], [...keys.value, keyCounter++]); + } + + function prepend(value: T): void { + writeArray([value, ...currentArray()], [keyCounter++, ...keys.value]); + form!._remapFieldPaths(resolvePath(), index => index + 1); + } + + function insert(index: number, value: T): void { + writeArray(insertAt(currentArray(), index, value), insertAt(keys.value, index, keyCounter++)); + form!._remapFieldPaths(resolvePath(), current => (current >= index ? current + 1 : current)); + } + + function remove(index: number): void { + writeArray(removeAt(currentArray(), index), removeAt(keys.value, index)); + form!._remapFieldPaths(resolvePath(), current => + current === index ? null : current > index ? current - 1 : current); + } + + function move(from: number, to: number): void { + writeArray(moveItem(currentArray(), from, to), moveItem(keys.value, from, to)); + const order = moveItem(Array.from({ length: currentArray().length }, (_unused, index) => index), from, to); + form!._remapFieldPaths(resolvePath(), current => order.indexOf(current)); + } + + function swap(indexA: number, indexB: number): void { + writeArray(swapItems(currentArray(), indexA, indexB), swapItems(keys.value, indexA, indexB)); + form!._remapFieldPaths(resolvePath(), current => + current === indexA ? indexB : current === indexB ? indexA : current); + } + + function replace(values: T[]): void { + writeArray([...values], values.map(() => keyCounter++)); + } + + function update(index: number, value: T): void { + const next = currentArray().slice(); + if (index < 0 || index >= next.length) + return; + next[index] = value; + writeArray(next, keys.value.slice()); + } + + return { + fields: fields as ComputedRef>>, + push, + prepend, + insert, + remove, + move, + swap, + replace, + update, + }; +} diff --git a/vue/toolkit/src/composables/forms/useForm/context.ts b/vue/toolkit/src/composables/forms/useForm/context.ts new file mode 100644 index 0000000..4c8d034 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useForm/context.ts @@ -0,0 +1,25 @@ +import { inject } from 'vue'; +import { useContextFactory } from '@/composables/state/useContextFactory'; +import type { FormContext } from './types'; + +// Reuse the toolkit's context factory for the unique key + provider rather than +// hand-rolling a Symbol + provide/inject. +const formContextFactory = /* #__PURE__ */ useContextFactory('VueToolsErrorForm'); + +/** + * The shared injection key under which {@link useForm} provides its context. + */ +export const FORM_CONTEXT_KEY = formContextFactory.key; + +/** + * Provide a form context to descendant components (called by `useForm`). + */ +export const provideFormContext = formContextFactory.provide; + +/** + * Inject the nearest provided form context, or `null` when there is none. + * (The factory's own `inject` throws when absent; fields need an optional one.) + */ +export function injectFormContext(): FormContext | null { + return inject(FORM_CONTEXT_KEY, null) as FormContext | null; +} diff --git a/vue/toolkit/src/composables/forms/useForm/demo.vue b/vue/toolkit/src/composables/forms/useForm/demo.vue new file mode 100644 index 0000000..2628ae1 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useForm/demo.vue @@ -0,0 +1,153 @@ + + + diff --git a/vue/toolkit/src/composables/forms/useForm/index.test.ts b/vue/toolkit/src/composables/forms/useForm/index.test.ts new file mode 100644 index 0000000..129375d --- /dev/null +++ b/vue/toolkit/src/composables/forms/useForm/index.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { flushPromises, mount } from '@vue/test-utils'; +import { useForm } from '.'; +import type { UseFormOptions, UseFormReturn } from '.'; +import type { StandardSchemaIssue, StandardSchemaV1 } from '@/types'; + +/** + * A hand-rolled Standard Schema (no zod dependency) proving `~standard` interop. + * Each entry returns a message string for an invalid field, or `undefined`. + */ +function makeSchema>( + rules: { [K in keyof T]?: (value: any) => string | undefined }, +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate(value: any) { + const issues: StandardSchemaIssue[] = []; + for (const key of Object.keys(rules)) { + const message = rules[key]!(value?.[key]); + if (message) + issues.push({ message, path: [key] }); + } + return issues.length > 0 ? { issues } : { value: value as T }; + }, + }, + }; +} + +function mountForm( + options: UseFormOptions, +) { + let form!: UseFormReturn; + const wrapper = mount(defineComponent({ + setup() { + form = useForm(options); + return () => h('form'); + }, + })); + return { form: form!, wrapper }; +} + +describe(useForm, () => { + it('exposes reactive values from initialValues', () => { + const { form } = mountForm({ initialValues: { email: 'a@b.com', age: 30 } }); + + expect(form.values.email).toBe('a@b.com'); + expect(form.getFieldValue('age')).toBe(30); + }); + + it('writes a field value by path', () => { + const { form } = mountForm({ initialValues: { address: { city: '' } } }); + + form.setFieldValue('address.city', 'NYC'); + + expect(form.values.address.city).toBe('NYC'); + expect(form.getFieldValue('address.city')).toBe('NYC'); + }); + + it('tracks dirty state against the initial snapshot', () => { + const { form } = mountForm({ initialValues: { name: 'Jane' } }); + + expect(form.isDirty.value).toBeFalsy(); + form.setFieldValue('name', 'John'); + expect(form.isDirty.value).toBeTruthy(); + expect(form.isFieldDirty('name')).toBeTruthy(); + form.setFieldValue('name', 'Jane'); + expect(form.isDirty.value).toBeFalsy(); + }); + + it('validates with a Standard Schema and maps issues by path', async () => { + const { form } = mountForm({ + initialValues: { email: '', age: 0 }, + schema: makeSchema<{ email: string; age: number }>({ + email: v => (typeof v === 'string' && v.includes('@') ? undefined : 'Invalid email'), + age: v => (v >= 18 ? undefined : 'Too young'), + }), + }); + + const result = await form.validate(); + + expect(result.valid).toBeFalsy(); + expect(form.getError('email')).toBe('Invalid email'); + expect(form.getError('age')).toBe('Too young'); + expect(form.isValid.value).toBeFalsy(); + + form.setFieldValue('email', 'a@b.com'); + form.setFieldValue('age', 21); + const ok = await form.validate(); + + expect(ok.valid).toBeTruthy(); + expect(ok.output).toEqual({ email: 'a@b.com', age: 21 }); + expect(form.isValid.value).toBeTruthy(); + }); + + it('supports a custom resolver', async () => { + const { form } = mountForm<{ pin: string }>({ + initialValues: { pin: '12' }, + resolver: values => (values.pin.length === 4 ? { values } : { errors: { pin: ['Need 4 digits'] } }), + }); + + expect((await form.validate()).valid).toBeFalsy(); + expect(form.getError('pin')).toBe('Need 4 digits'); + + form.setFieldValue('pin', '1234'); + expect((await form.validate()).valid).toBeTruthy(); + }); + + it('merges per-field function validators with the schema', async () => { + const { form } = mountForm<{ password: string; confirm: string }>({ + initialValues: { password: 'secret', confirm: 'nope' }, + }); + + form.defineField('confirm', { + validate: (value, values) => (value === values.password ? true : 'Passwords must match'), + }); + + expect((await form.validate()).valid).toBeFalsy(); + expect(form.getError('confirm')).toBe('Passwords must match'); + + form.setFieldValue('confirm', 'secret'); + expect((await form.validate()).valid).toBeTruthy(); + }); + + it('handleSubmit calls onValid with typed output and bumps submitCount', async () => { + const onValid = vi.fn(); + const { form } = mountForm({ + initialValues: { name: 'Jane' }, + schema: makeSchema<{ name: string }>({ name: v => (v ? undefined : 'Required') }), + }); + + await form.handleSubmit(onValid)(); + + expect(onValid).toHaveBeenCalledWith({ name: 'Jane' }, undefined); + expect(form.submitCount.value).toBe(1); + }); + + it('handleSubmit calls onInvalid and marks errored fields touched', async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const { form } = mountForm({ + initialValues: { name: '' }, + schema: makeSchema<{ name: string }>({ name: v => (v ? undefined : 'Required') }), + }); + + await form.handleSubmit(onValid, onInvalid)(); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledTimes(1); + expect(form.isFieldTouched('name')).toBeTruthy(); + }); + + it('prevents the native submit event default', async () => { + const { form } = mountForm({ initialValues: { a: 1 } }); + const event = { preventDefault: vi.fn() } as unknown as Event; + + await form.handleSubmit(vi.fn())(event); + + expect((event.preventDefault as ReturnType)).toHaveBeenCalled(); + }); + + it('setValues merges by default and replaces when asked', () => { + const { form } = mountForm({ initialValues: { a: 1, b: 2 } }); + + form.setValues({ a: 9 }); + expect(form.values).toEqual({ a: 9, b: 2 }); + + form.setValues({ a: 5 }, { merge: false }); + expect(form.values).toEqual({ a: 5 }); + }); + + it('sets and clears field errors directly', () => { + const { form } = mountForm({ initialValues: { name: '' } }); + + form.setFieldError('name', 'Bad'); + expect(form.getError('name')).toBe('Bad'); + + form.setFieldError('name', null); + expect(form.getErrors('name')).toEqual([]); + + form.setErrors({ name: ['One', 'Two'] }); + expect(form.getErrors('name')).toEqual(['One', 'Two']); + }); + + it('resets values, errors, touched and submitCount', async () => { + const { form } = mountForm({ + initialValues: { name: 'Jane' }, + schema: makeSchema<{ name: string }>({ name: () => 'always-bad' }), + }); + + form.setFieldValue('name', 'John'); + form.setFieldTouched('name', true); + await form.handleSubmit(vi.fn())(); + + form.resetForm(); + + expect(form.values.name).toBe('Jane'); + expect(form.getErrors('name')).toEqual([]); + expect(form.isFieldTouched('name')).toBeFalsy(); + expect(form.submitCount.value).toBe(0); + }); + + it('resetField restores a single field to its initial value', () => { + const { form } = mountForm({ initialValues: { a: 1, b: 2 } }); + + form.setFieldValue('a', 10); + form.setFieldValue('b', 20); + form.resetField('a'); + + expect(form.values.a).toBe(1); + expect(form.values.b).toBe(20); + }); + + it('defineField yields a working v-model + props pair', async () => { + const { form } = mountForm({ + initialValues: { name: '' }, + schema: makeSchema<{ name: string }>({ name: v => (v ? undefined : 'Required') }), + }); + + const [model, props] = form.defineField('name'); + + expect(props.value.name).toBe('name'); + expect(props.value['aria-invalid']).toBeUndefined(); + + model.value = 'Jane'; + expect(form.values.name).toBe('Jane'); + + form.setFieldError('name', 'Required'); + await nextTick(); + expect(props.value['aria-invalid']).toBeTruthy(); + }); + + it('validateOnMount validates immediately', async () => { + const { form } = mountForm({ + initialValues: { name: '' }, + schema: makeSchema<{ name: string }>({ name: v => (v ? undefined : 'Required') }), + validateOnMount: true, + }); + + await flushPromises(); + expect(form.getError('name')).toBe('Required'); + }); +}); diff --git a/vue/toolkit/src/composables/forms/useForm/index.ts b/vue/toolkit/src/composables/forms/useForm/index.ts new file mode 100644 index 0000000..0d36a7a --- /dev/null +++ b/vue/toolkit/src/composables/forms/useForm/index.ts @@ -0,0 +1,506 @@ +import { computed, reactive, readonly, ref, toValue } from 'vue'; +import type { ComputedRef, Ref } from 'vue'; +import { get, isEqual, isObject, set, toArray } from '@robonen/stdlib'; +import { cloneFnDefault } from '@/composables/reactivity/useCloned'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; +import { provideFormContext } from './context'; +import { normalizeFieldResult, runStandardSchema } from './validation'; +import type { + FieldBindingProps, + FieldPath, + FieldPathValue, + FieldValidationResultDetail, + FieldValidator, + FormErrors, + FormMeta, + FormResetState, + FormValidationResult, + InvalidSubmissionHandler, + PartialDeep, + SetValueOptions, + SubmissionHandler, + UseFormOptions, + UseFormReturn, + ValidationTrigger, +} from './types'; + +export type { + FieldArrayEntry, + FieldBindingProps, + FieldMeta, + FieldPath, + FieldPathValue, + FieldValidationResult, + FieldValidationResultDetail, + FieldValidator, + FormContext, + FormErrors, + FormMeta, + FormResetState, + FormResolver, + FormResolverResult, + FormValidationResult, + InvalidSubmissionHandler, + PartialDeep, + SetValueOptions, + SubmissionHandler, + UseFieldArrayOptions, + UseFieldArrayReturn, + UseFieldOptions, + UseFieldReturn, + UseFormOptions, + UseFormReturn, + ValidationTrigger, +} from './types'; + +/** + * Recursively assign `source` into the reactive `target`, deep-merging plain + * objects and cloning leaf values so callers can't share mutable references. + */ +function deepAssign(target: Record, source: Record): void { + for (const key of Object.keys(source)) { + const value = source[key]; + const current = target[key]; + + if (isObject(value) && isObject(current)) + deepAssign(current, value); + else + target[key] = cloneFnDefault(value); + } +} + +/** + * @name useForm + * @category Forms + * @description Headless, performant form state management. Holds reactive `values`, + * flat path-keyed `errors`/touched maps, derived `meta`, and a full set of + * mutation/validation/submit/reset helpers. Validation accepts a + * [Standard Schema](https://github.com/standard-schema/standard-schema) + * (zod/valibot/arktype), a custom resolver, or per-field function validators. + * + * @param {UseFormOptions} [options={}] Initial values, schema/resolver, and validation triggers + * @returns {UseFormReturn} The reactive form instance (also provided to descendant fields) + * + * @example + * const { values, errors, handleSubmit } = useForm({ + * initialValues: { email: '', age: 0 }, + * schema: z.object({ email: z.string().email(), age: z.number().min(18) }), + * }); + * const onSubmit = handleSubmit((output) => save(output)); + * + * @example + * // Inline binding with defineField + * const form = useForm({ initialValues: { name: '' } }); + * const [name, nameProps] = form.defineField('name'); + * // + * + * @since 0.0.16 + */ +export function useForm( + options: UseFormOptions = {}, +): UseFormReturn { + const { + schema, + resolver, + validateOnMount = false, + validateOn = 'submit', + revalidateOn = 'value', + } = options; + + // Cloned snapshot of the initial values, used as the dirty/reset baseline. + let initialSnapshot = cloneFnDefault((toValue(options.initialValues) ?? {}) as PartialDeep); + + const values = reactive(cloneFnDefault(initialSnapshot) as object) as TInput; + const errorsMap = ref({}); + const touchedMap = reactive>({}); + + const isValidating = ref(false); + const isSubmitting = ref(false); + const submitCount = ref(0); + + // Field-level function validators registered by useField/defineField. + const fieldValidators = new Map>(); + // Every path a field has been declared for (drives setTouched("all")). + const knownFields = new Set(); + + function readPath(path: string): any { + return get(values as any, path); + } + + // ---- derived state ---------------------------------------------------- + + const errors = computed(() => errorsMap.value); + const isValid = computed(() => Object.keys(errorsMap.value).length === 0); + const isDirty = computed(() => !isEqual(values, initialSnapshot)); + const isTouched = computed(() => Object.values(touchedMap).some(Boolean)); + + const meta = computed(() => ({ + dirty: isDirty.value, + valid: isValid.value, + touched: isTouched.value, + pending: isValidating.value, + })); + + // ---- trigger gating --------------------------------------------------- + + function effectiveTrigger(override?: ValidationTrigger): ValidationTrigger { + return override ?? (submitCount.value > 0 ? revalidateOn : validateOn); + } + + function shouldValidate(event: 'value' | 'blur', override?: ValidationTrigger): boolean { + const trigger = effectiveTrigger(override); + if (trigger === 'manual' || trigger === 'submit') + return false; + if (trigger === 'value') + return true; + return event === 'blur'; + } + + // ---- validation pipeline --------------------------------------------- + + let validationSeq = 0; + + async function runValidation(): Promise> { + // Validate against the live reactive `values` — schema/resolver/validators + // only READ it — so there is no deep clone on every keystroke. A plain + // snapshot is materialised only for the (schemaless) success output. + let resultErrors: FormErrors = {}; + let output: TOutput | undefined; + + if (schema) { + const run = await runStandardSchema(schema, values); + resultErrors = run.errors; + output = run.output; + } + else if (resolver) { + const run = await resolver(values as TInput); + resultErrors = run.errors ?? {}; + output = run.values; + } + + // Merge per-field function validators on top of schema/resolver errors. + for (const [path, validators] of fieldValidators) { + for (const validator of validators) { + const messages = normalizeFieldResult(await validator(get(values as any, path), values as TInput)); + if (messages.length > 0) + (resultErrors[path] ??= []).push(...messages); + } + } + + const valid = Object.keys(resultErrors).length === 0; + + return { + valid, + errors: resultErrors, + output: valid ? (output ?? (cloneFnDefault(values) as unknown as TOutput)) : undefined, + }; + } + + async function validate(): Promise> { + const seq = ++validationSeq; + isValidating.value = true; + + try { + const result = await runValidation(); + // Apply only if this is still the latest run (drop stale async results). + if (seq === validationSeq) + errorsMap.value = result.errors; + return result; + } + finally { + if (seq === validationSeq) + isValidating.value = false; + } + } + + async function validateField(path: FieldPath): Promise { + const result = await validate(); + const fieldErrors = result.errors[path as string] ?? []; + return { valid: fieldErrors.length === 0, errors: fieldErrors }; + } + + // ---- queries ---------------------------------------------------------- + + function getFieldValue

>(path: P): FieldPathValue { + return readPath(path as string) as FieldPathValue; + } + + function getErrors(path: FieldPath): string[] { + return errorsMap.value[path as string] ?? []; + } + + function getError(path: FieldPath): string | undefined { + return getErrors(path)[0]; + } + + function isFieldDirty(path: FieldPath): boolean { + return !isEqual(readPath(path as string), get(initialSnapshot as any, path as string)); + } + + function isFieldTouched(path: FieldPath): boolean { + return touchedMap[path as string] === true; + } + + function isFieldValid(path: FieldPath): boolean { + return getErrors(path).length === 0; + } + + // ---- mutations -------------------------------------------------------- + + function setFieldValue

>( + path: P, + value: FieldPathValue, + setOptions?: SetValueOptions, + ): void { + set(values as any, path as string, value); + + if (setOptions?.shouldTouch) { + touchedMap[path as string] = true; + knownFields.add(path as string); + } + + const should = setOptions?.shouldValidate ?? shouldValidate('value'); + if (should) + void validate(); + } + + function setValues(next: PartialDeep, setOptions?: { merge?: boolean }): void { + const merge = setOptions?.merge ?? true; + + if (!merge) { + for (const key of Object.keys(values as object)) + delete (values as Record)[key]; + } + + deepAssign(values as Record, next as Record); + + if (shouldValidate('value')) + void validate(); + } + + function setFieldError(path: FieldPath, message: string | string[] | null): void { + if (message === null) { + delete errorsMap.value[path as string]; + return; + } + + errorsMap.value[path as string] = toArray(message); + } + + function setErrors(next: FormErrors): void { + errorsMap.value = { ...next }; + } + + function setFieldTouched(path: FieldPath, touched = true): void { + touchedMap[path as string] = touched; + knownFields.add(path as string); + } + + function setTouched(touched = true): void { + for (const path of knownFields) + touchedMap[path] = touched; + } + + // ---- reset ------------------------------------------------------------ + + function resetForm(state?: FormResetState): void { + if (state?.values !== undefined) + initialSnapshot = cloneFnDefault(state.values); + + for (const key of Object.keys(values as object)) + delete (values as Record)[key]; + deepAssign(values as Record, initialSnapshot as Record); + + errorsMap.value = state?.errors ? { ...state.errors } : {}; + + for (const key of Object.keys(touchedMap)) + delete touchedMap[key]; + if (state?.touched) { + for (const [key, value] of Object.entries(state.touched)) + touchedMap[key] = value; + } + + submitCount.value = 0; + } + + function resetField

>(path: P, value?: FieldPathValue): void { + const next = value !== undefined ? value : get(initialSnapshot as any, path as string); + set(values as any, path as string, cloneFnDefault(next)); + delete errorsMap.value[path as string]; + delete touchedMap[path as string]; + } + + // ---- handlers --------------------------------------------------------- + + function handleSubmit( + onValid: SubmissionHandler, + onInvalid?: InvalidSubmissionHandler, + ): (event?: Event) => Promise { + return async (event?: Event) => { + event?.preventDefault?.(); + submitCount.value += 1; + isSubmitting.value = true; + + try { + const result = await validate(); + + if (result.valid) { + await onValid(result.output as TOutput, event); + } + else { + for (const path of Object.keys(result.errors)) { + touchedMap[path] = true; + knownFields.add(path); + } + onInvalid?.(result.errors, event); + } + } + finally { + isSubmitting.value = false; + } + }; + } + + function handleReset(event?: Event): void { + event?.preventDefault?.(); + resetForm(); + } + + function remapFieldPaths(basePath: string, indexMap: (index: number) => number | null): void { + const prefix = `${basePath}.`; + + const rewriteKey = (key: string): string | null => { + if (!key.startsWith(prefix)) + return key; + + const rest = key.slice(prefix.length); + const dot = rest.indexOf('.'); + const index = Number(dot === -1 ? rest : rest.slice(0, dot)); + if (!Number.isInteger(index)) + return key; + + const mapped = indexMap(index); + if (mapped === null) + return null; + + return `${prefix}${mapped}${dot === -1 ? '' : rest.slice(dot)}`; + }; + + const nextErrors: FormErrors = {}; + for (const key of Object.keys(errorsMap.value)) { + const mappedKey = rewriteKey(key); + if (mappedKey !== null) + nextErrors[mappedKey] = errorsMap.value[key]!; + } + errorsMap.value = nextErrors; + + const touchedEntries = Object.entries(touchedMap); + for (const key of Object.keys(touchedMap)) + delete touchedMap[key]; + for (const [key, value] of touchedEntries) { + const mappedKey = rewriteKey(key); + if (mappedKey !== null) + touchedMap[mappedKey] = value; + } + } + + // ---- binding ---------------------------------------------------------- + + function registerValidator(path: string, validator: FieldValidator): void { + let set_ = fieldValidators.get(path); + if (!set_) { + set_ = new Set(); + fieldValidators.set(path, set_); + } + set_.add(validator); + knownFields.add(path); + } + + function unregisterValidator(path: string, validator: FieldValidator): void { + const set_ = fieldValidators.get(path); + if (!set_) + return; + set_.delete(validator); + if (set_.size === 0) + fieldValidators.delete(path); + } + + function defineField

>( + path: P, + fieldOptions?: { validate?: FieldValidator, TInput>; validateOn?: ValidationTrigger }, + ): [Ref>, ComputedRef] { + knownFields.add(path as string); + + if (fieldOptions?.validate) + registerValidator(path as string, fieldOptions.validate as FieldValidator); + + const model = computed>({ + get: () => getFieldValue(path), + set: value => setFieldValue(path, value, { shouldValidate: shouldValidate('value', fieldOptions?.validateOn) }), + }); + + const props = computed(() => ({ + name: path as string, + onBlur: () => { + touchedMap[path as string] = true; + if (shouldValidate('blur', fieldOptions?.validateOn)) + void validate(); + }, + 'aria-invalid': getErrors(path).length > 0 ? true : undefined, + })); + + return [model as Ref>, props]; + } + + const formProps = { + onSubmit: (event: Event): void => { + event.preventDefault(); + }, + onReset: handleReset, + novalidate: true, + }; + + const context: UseFormReturn = { + values, + errors, + meta, + isDirty, + isValid, + isValidating: readonly(isValidating), + isSubmitting: readonly(isSubmitting), + submitCount: readonly(submitCount), + getFieldValue, + getError, + getErrors, + isFieldDirty, + isFieldTouched, + isFieldValid, + setFieldValue, + setValues, + setFieldError, + setErrors, + setFieldTouched, + setTouched, + validate, + validateField, + resetForm, + resetField, + handleSubmit, + handleReset, + defineField, + formProps, + _registerValidator: registerValidator, + _unregisterValidator: unregisterValidator, + _shouldValidate: trigger => (trigger === 'submit' ? true : shouldValidate(trigger)), + _remapFieldPaths: remapFieldPaths, + }; + + provideFormContext(context as UseFormReturn); + + tryOnMounted(() => { + if (validateOnMount) + void validate(); + }); + + return context; +} diff --git a/vue/toolkit/src/composables/forms/useForm/types.ts b/vue/toolkit/src/composables/forms/useForm/types.ts new file mode 100644 index 0000000..b59c7c7 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useForm/types.ts @@ -0,0 +1,608 @@ +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; +import type { StandardSchemaV1 } from '@/types'; + +/** + * Values that cannot be descended into when building field paths. + */ +type FieldPrimitive + = | string + | number + | boolean + | bigint + | symbol + | null + | undefined + | Date + | RegExp + | ((...args: any[]) => any); + +/** + * Union of every dot-separated path into `T` (including array indices), e.g. + * `'email' | 'address' | 'address.city' | 'tags.0'`. + */ +export type FieldPath = T extends FieldPrimitive + ? never + : T extends ReadonlyArray + ? `${number}` | `${number}.${FieldPath}` + : { + [K in keyof T & (string | number)]: T[K] extends FieldPrimitive + ? `${K}` + : `${K}` | `${K}.${FieldPath}`; + }[keyof T & (string | number)]; + +/** + * The value type at a given {@link FieldPath} of `T`. + */ +export type FieldPathValue = P extends `${infer K}.${infer Rest}` + ? K extends keyof T + ? FieldPathValue + : T extends ReadonlyArray + ? FieldPathValue + : unknown + : P extends keyof T + ? T[P] + : T extends ReadonlyArray + ? U + : unknown; + +/** + * A recursively-partial version of `T` (used for `initialValues`/`setValues`). + */ +export type PartialDeep = T extends FieldPrimitive + ? T + : T extends ReadonlyArray + ? Array> + : { [K in keyof T]?: PartialDeep }; + +/** + * When validation runs for a field/form. + * - `value` — on every value change + * - `blur` — when the field is blurred + * - `submit` — only on submit + * - `manual` — never automatically; only via `validate()`/`validateField()` + */ +export type ValidationTrigger = 'value' | 'blur' | 'submit' | 'manual'; + +/** + * Flat map of field path → error messages. + */ +export type FormErrors = Record; + +/** + * The value a field-level function validator may return. A `string`/`string[]` + * is treated as error message(s); `true`/`void`/`null`/`undefined` means valid. + */ +export type FieldValidationResult = string | string[] | true | void | null | undefined; + +/** + * A field-level function validator. + */ +export type FieldValidator + = (value: T, values: TInput) => FieldValidationResult | Promise; + +/** + * A custom form-level resolver (alternative to a Standard Schema). + */ +export type FormResolver + = (values: TInput) => FormResolverResult | Promise>; + +/** + * The shape returned by a {@link FormResolver}. + */ +export interface FormResolverResult { + /** + * The (optionally transformed) valid output. Read on success. + */ + values?: TOutput; + /** + * Flat map of path → messages. Empty/absent means valid. + */ + errors?: FormErrors; +} + +/** + * The outcome of running form validation. + */ +export interface FormValidationResult { + /** + * Whether the form is valid (no errors). + */ + valid: boolean; + /** + * The flat error map produced by this run. + */ + errors: FormErrors; + /** + * The typed, validated output (present only when `valid`). + */ + output?: TOutput; +} + +/** + * The outcome of validating a single field. + */ +export interface FieldValidationResultDetail { + /** + * Whether the field is valid. + */ + valid: boolean; + /** + * The field's error messages (empty when valid). + */ + errors: string[]; +} + +/** + * Reactive meta flags describing the whole form. + */ +export interface FormMeta { + /** + * Whether any value differs from its initial snapshot. + */ + dirty: boolean; + /** + * Whether the form currently has no errors. + */ + valid: boolean; + /** + * Whether any field has been touched. + */ + touched: boolean; + /** + * Whether a validation run is in flight. + */ + pending: boolean; +} + +/** + * Reactive meta flags describing a single field. + */ +export interface FieldMeta { + /** + * Whether the field's value differs from its initial snapshot. + */ + dirty: ComputedRef; + /** + * Whether the field has been touched (blurred). + */ + touched: ComputedRef; + /** + * Whether the field currently has no errors. + */ + valid: ComputedRef; +} + +/** + * Props to spread onto a native field element (via `v-bind`). + */ +export interface FieldBindingProps { + /** + * The field's dotted path, suitable as the input `name`. + */ + name: string; + /** + * Blur handler that marks the field touched and (re)validates per trigger. + */ + onBlur: (event?: Event) => void; + /** + * `true` when the field currently has errors, for `aria-invalid`. + */ + 'aria-invalid': boolean | undefined; +} + +/** + * Options for {@link UseFormOptions}'s value-write helpers. + */ +export interface SetValueOptions { + /** + * Whether to (re)validate after the write. Defaults to the form's trigger config. + */ + shouldValidate?: boolean; + /** + * Whether to mark the field touched. Defaults to `false`. + */ + shouldTouch?: boolean; +} + +/** + * State accepted by `resetForm`. + */ +export interface FormResetState { + /** + * New baseline values (defaults to the original initial values). + */ + values?: PartialDeep; + /** + * New touched map (defaults to empty). + */ + touched?: Record; + /** + * New error map (defaults to empty). + */ + errors?: FormErrors; +} + +/** + * The success callback for `handleSubmit`. + */ +export type SubmissionHandler = (values: TOutput, event?: Event) => void | Promise; + +/** + * The invalid callback for `handleSubmit`. + */ +export type InvalidSubmissionHandler = (errors: FormErrors, event?: Event) => void; + +/** + * Options for {@link useForm}. + */ +export interface UseFormOptions { + /** + * Initial form values (ref/getter supported; cloned on init). + */ + initialValues?: MaybeRefOrGetter>; + /** + * A Standard Schema (zod/valibot/arktype/…) validating the whole form. + */ + schema?: StandardSchemaV1; + /** + * A custom form-level resolver (alternative to `schema`). + */ + resolver?: FormResolver; + /** + * Validate once on mount. + * + * @default false + */ + validateOnMount?: boolean; + /** + * When to validate before the first submit. + * + * @default 'submit' + */ + validateOn?: ValidationTrigger; + /** + * When to validate after the first submit (or after a field is touched). + * + * @default 'value' + */ + revalidateOn?: ValidationTrigger; +} + +/** + * The form instance returned by {@link useForm} (and injected by + * {@link useFormContext}). Also serves as the context shared with fields. + */ +export interface FormContext { + /** + * Reactive form values. Bind directly with `v-model="values.path"`. + */ + values: TInput; + /** + * Flat, reactive map of path → error messages. + */ + errors: ComputedRef; + /** + * Grouped reactive meta flags for the whole form. + */ + meta: ComputedRef; + + /** + * Whether any value differs from the initial snapshot. + */ + isDirty: ComputedRef; + /** + * Whether the form has no errors. + */ + isValid: ComputedRef; + /** + * Whether a validation run is in flight. + */ + isValidating: Readonly>; + /** + * Whether a submit is in flight. + */ + isSubmitting: Readonly>; + /** + * Number of times submit has been attempted. + */ + submitCount: Readonly>; + + /** + * Read a field value by path. + */ + getFieldValue:

>(path: P) => FieldPathValue; + /** + * The first error message for a path, if any. + */ + getError: (path: FieldPath) => string | undefined; + /** + * All error messages for a path (empty array when none). + */ + getErrors: (path: FieldPath) => string[]; + /** + * Whether a field differs from its initial snapshot. + */ + isFieldDirty: (path: FieldPath) => boolean; + /** + * Whether a field has been touched. + */ + isFieldTouched: (path: FieldPath) => boolean; + /** + * Whether a field currently has no errors. + */ + isFieldValid: (path: FieldPath) => boolean; + + /** + * Write a field value by path. + */ + setFieldValue:

>( + path: P, + value: FieldPathValue, + options?: SetValueOptions, + ) => void; + /** + * Merge or replace multiple values at once. + */ + setValues: (values: PartialDeep, options?: { merge?: boolean }) => void; + /** + * Set or clear (with `null`) a field's error messages. + */ + setFieldError: (path: FieldPath, message: string | string[] | null) => void; + /** + * Replace the entire error map. + */ + setErrors: (errors: FormErrors) => void; + /** + * Mark a field touched/untouched. + */ + setFieldTouched: (path: FieldPath, touched?: boolean) => void; + /** + * Mark all known fields touched/untouched. + */ + setTouched: (touched?: boolean) => void; + + /** + * Validate the whole form. + */ + validate: () => Promise>; + /** + * Validate a single field (runs the pipeline, updates that field's errors). + */ + validateField: (path: FieldPath) => Promise; + + /** + * Reset the form to its initial (or provided) state. + */ + resetForm: (state?: FormResetState) => void; + /** + * Reset a single field to its initial (or provided) value. + */ + resetField:

>(path: P, value?: FieldPathValue) => void; + + /** + * Wrap a submit callback: validates, then calls `onValid` with typed output + * (or `onInvalid` with the error map). + */ + handleSubmit: ( + onValid: SubmissionHandler, + onInvalid?: InvalidSubmissionHandler, + ) => (event?: Event) => Promise; + /** + * Reset handler suitable for a `

`'s `reset` event. + */ + handleReset: (event?: Event) => void; + + /** + * Bind a field inline: returns `[model, props]` for `v-model` + `v-bind`. + */ + defineField:

>( + path: P, + options?: DefineFieldOptions, TInput>, + ) => [Ref>, ComputedRef]; + + /** + * Props to spread on the `` element (`@submit`/`@reset`/`novalidate`). + */ + formProps: { + onSubmit: (event: Event) => void; + onReset: (event: Event) => void; + novalidate: boolean; + }; + + /** + * @internal Register a field-level validator for a path (used by `useField`). + */ + _registerValidator: (path: string, validator: FieldValidator) => void; + /** + * @internal Unregister a field-level validator for a path. + */ + _unregisterValidator: (path: string, validator: FieldValidator) => void; + /** + * @internal Whether validation should run for a given trigger right now. + */ + _shouldValidate: (trigger: Exclude) => boolean; + /** + * @internal Re-key error/touched entries under `basePath` after an array + * reorder. `indexMap` maps an old array index to its new index, or `null` + * to drop it (removal). Used by `useFieldArray`. + */ + _remapFieldPaths: (basePath: string, indexMap: (index: number) => number | null) => void; +} + +/** + * Alias: the public return of {@link useForm} is the form context itself. + */ +export type UseFormReturn = FormContext; + +/** + * Options for `defineField`. + */ +export interface DefineFieldOptions { + /** + * A field-level function validator. + */ + validate?: FieldValidator; + /** + * Override the form's validation trigger for this field. + */ + validateOn?: ValidationTrigger; +} + +/** + * Options for {@link useField}. + */ +export interface UseFieldOptions { + /** + * The form to bind to. Defaults to the injected form context; if there is + * none and `initialValue` is given, the field runs standalone. + */ + form?: FormContext; + /** + * Initial value for standalone mode (no form context). + */ + initialValue?: T; + /** + * A field-level function validator. + */ + validate?: FieldValidator; + /** + * A per-field Standard Schema (standalone or augmenting the form schema). + */ + schema?: StandardSchemaV1; + /** + * Override the form's validation trigger for this field. + */ + validateOn?: ValidationTrigger; +} + +/** + * The reactive API returned by {@link useField}. + */ +export interface UseFieldReturn { + /** + * The field's writable value (bound to the form path, or local in standalone). + */ + value: Ref; + /** + * The field's error messages. + */ + errors: ComputedRef; + /** + * The field's first error message, if any. + */ + errorMessage: ComputedRef; + /** + * Grouped reactive meta for the field. + */ + meta: FieldMeta; + /** + * Blur handler — marks touched and (re)validates per trigger. + */ + handleBlur: (event?: Event) => void; + /** + * Set the value and optionally validate. + */ + handleChange: (value: T, shouldValidate?: boolean) => void; + /** + * `input`/`change` DOM handler for native elements. + */ + handleInput: (event: Event) => void; + /** + * Set the field value programmatically. + */ + setValue: (value: T) => void; + /** + * Mark the field touched/untouched. + */ + setTouched: (touched?: boolean) => void; + /** + * Set or clear (with `null`) the field's errors. + */ + setErrors: (message: string | string[] | null) => void; + /** + * Validate just this field. + */ + validate: () => Promise; + /** + * Reset the field to its initial value. + */ + reset: () => void; + /** + * Props to spread on the field element (`v-bind="attrs"`). + */ + attrs: ComputedRef; +} + +/** + * One entry of a {@link useFieldArray}. + */ +export interface FieldArrayEntry { + /** + * A stable key for `v-for`, preserved across reorders. + */ + key: number; + /** + * The item's writable value (bound to the array slot). + */ + value: Ref; + /** + * Whether this is the first entry. + */ + isFirst: boolean; + /** + * Whether this is the last entry. + */ + isLast: boolean; +} + +/** + * The API returned by {@link useFieldArray}. + */ +export interface UseFieldArrayReturn { + /** + * The reactive list of entries with stable keys. + */ + fields: Readonly>>>; + /** + * Append an item. + */ + push: (value: T) => void; + /** + * Prepend an item. + */ + prepend: (value: T) => void; + /** + * Insert an item at an index. + */ + insert: (index: number, value: T) => void; + /** + * Remove the item at an index. + */ + remove: (index: number) => void; + /** + * Move an item from one index to another. + */ + move: (from: number, to: number) => void; + /** + * Swap two items. + */ + swap: (indexA: number, indexB: number) => void; + /** + * Replace the whole array. + */ + replace: (values: T[]) => void; + /** + * Replace a single item. + */ + update: (index: number, value: T) => void; +} + +/** + * Options for {@link useFieldArray}. + */ +export interface UseFieldArrayOptions { + /** + * The form to bind to. Defaults to the injected form context. + */ + form?: FormContext; +} diff --git a/vue/toolkit/src/composables/forms/useForm/validation.ts b/vue/toolkit/src/composables/forms/useForm/validation.ts new file mode 100644 index 0000000..86d832c --- /dev/null +++ b/vue/toolkit/src/composables/forms/useForm/validation.ts @@ -0,0 +1,73 @@ +import { isArray } from '@robonen/stdlib'; +import type { StandardSchemaIssue, StandardSchemaV1 } from '@/types'; +import type { FieldValidationResult, FormErrors } from './types'; + +/** + * Convert a Standard Schema issue path into our dot-separated string form. + */ +export function issuePathToString(path: StandardSchemaIssue['path']): string { + if (!path || path.length === 0) + return ''; + + let result = ''; + + for (const segment of path) { + const key = typeof segment === 'object' ? segment.key : segment; + result = result === '' ? String(key) : `${result}.${String(key)}`; + } + + return result; +} + +/** + * Fold a list of Standard Schema issues into a flat `path → messages` map. + */ +export function issuesToErrors(issues: readonly StandardSchemaIssue[]): FormErrors { + const errors: FormErrors = {}; + + for (const issue of issues) { + const path = issuePathToString(issue.path); + (errors[path] ??= []).push(issue.message); + } + + return errors; +} + +/** + * The normalized outcome of validating a value against a Standard Schema. + */ +export interface StandardSchemaRun { + valid: boolean; + output?: Output; + errors: FormErrors; +} + +/** + * Validate a value against a Standard Schema, awaiting async schemas, and map + * the result into our `{ valid, output?, errors }` shape. + */ +export async function runStandardSchema( + schema: StandardSchemaV1, + value: unknown, +): Promise> { + const result = await schema['~standard'].validate(value); + + if (result.issues) + return { valid: false, errors: issuesToErrors(result.issues) }; + + return { valid: true, output: result.value, errors: {} }; +} + +/** + * Normalize the return of a field-level function validator into messages. + * `true`/`null`/`undefined`/`''` mean valid (empty array). + */ +export function normalizeFieldResult(result: FieldValidationResult): string[] { + if (result === true || result === null || result === undefined) + return []; + + if (isArray(result)) + return result.filter(message => message.length > 0); + + return result.length > 0 ? [result] : []; +} diff --git a/vue/toolkit/src/composables/forms/useFormContext/demo.vue b/vue/toolkit/src/composables/forms/useFormContext/demo.vue new file mode 100644 index 0000000..578a743 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useFormContext/demo.vue @@ -0,0 +1,91 @@ + + + diff --git a/vue/toolkit/src/composables/forms/useFormContext/index.test.ts b/vue/toolkit/src/composables/forms/useFormContext/index.test.ts new file mode 100644 index 0000000..44156ad --- /dev/null +++ b/vue/toolkit/src/composables/forms/useFormContext/index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { mount } from '@vue/test-utils'; +import { useForm } from '../useForm'; +import type { UseFormReturn } from '../useForm'; +import { useFormContext } from '.'; + +describe(useFormContext, () => { + it('returns null when no form is provided', () => { + let ctx: UseFormReturn | null = null as UseFormReturn | null; + + mount(defineComponent({ + setup() { + ctx = useFormContext(); + return () => h('div'); + }, + })); + + expect(ctx).toBeNull(); + }); + + it('injects the form provided by an ancestor', () => { + let injected: UseFormReturn | null = null; + let provided: UseFormReturn | undefined; + + const Child = defineComponent({ + setup() { + injected = useFormContext(); + return () => h('div'); + }, + }); + + const Parent = defineComponent({ + setup() { + provided = useForm({ initialValues: { a: 1 } }); + return () => h(Child); + }, + }); + + mount(Parent); + + expect(injected).toBe(provided); + }); +}); diff --git a/vue/toolkit/src/composables/forms/useFormContext/index.ts b/vue/toolkit/src/composables/forms/useFormContext/index.ts new file mode 100644 index 0000000..aa16818 --- /dev/null +++ b/vue/toolkit/src/composables/forms/useFormContext/index.ts @@ -0,0 +1,23 @@ +import { injectFormContext } from '../useForm/context'; +import type { UseFormReturn } from '../useForm'; + +/** + * @name useFormContext + * @category Forms + * @description Retrieve the {@link useForm} instance provided by an ancestor, for + * building field components that live anywhere in the form's subtree. Returns + * `null` when no form has been provided (so callers can support standalone use). + * + * @returns {UseFormReturn | null} The injected form instance, or `null` + * + * @example + * // Inside a custom rendered within a useForm() component: + * const form = useFormContext(); + * if (form) + * form.setFieldValue('email', 'a@b.com'); + * + * @since 0.0.16 + */ +export function useFormContext(): UseFormReturn | null { + return injectFormContext(); +} diff --git a/web/vue/src/composables/index.ts b/vue/toolkit/src/composables/index.ts similarity index 51% rename from web/vue/src/composables/index.ts rename to vue/toolkit/src/composables/index.ts index 2e69457..d53b753 100644 --- a/web/vue/src/composables/index.ts +++ b/vue/toolkit/src/composables/index.ts @@ -1,8 +1,16 @@ +export * from './animation'; +export * from './array'; export * from './browser'; export * from './component'; +export * from './debug'; +export * from './elements'; +export * from './forms'; export * from './lifecycle'; export * from './math'; +export * from './media'; export * from './reactivity'; +export * from './sensors'; export * from './state'; export * from './storage'; export * from './utilities'; +export * from './watch'; diff --git a/web/vue/src/composables/lifecycle/index.ts b/vue/toolkit/src/composables/lifecycle/index.ts similarity index 100% rename from web/vue/src/composables/lifecycle/index.ts rename to vue/toolkit/src/composables/lifecycle/index.ts diff --git a/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/demo.vue b/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/demo.vue new file mode 100644 index 0000000..a82fca7 --- /dev/null +++ b/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/demo.vue @@ -0,0 +1,76 @@ + + + diff --git a/web/vue/src/composables/lifecycle/tryOnBeforeMount/index.ts b/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts similarity index 95% rename from web/vue/src/composables/lifecycle/tryOnBeforeMount/index.ts rename to vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts index f6d2ee1..ca2d9d1 100644 --- a/web/vue/src/composables/lifecycle/tryOnBeforeMount/index.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts @@ -1,4 +1,4 @@ -import { onBeforeMount, nextTick } from 'vue'; +import { nextTick, onBeforeMount } from 'vue'; import type { ComponentInternalInstance } from 'vue'; import { getLifeCycleTarger } from '@/utils'; import type { VoidFunction } from '@robonen/stdlib'; @@ -14,19 +14,19 @@ export interface TryOnBeforeMountOptions { * @name tryOnBeforeMount * @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. * @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 = {}) { @@ -34,7 +34,7 @@ export function tryOnBeforeMount(fn: VoidFunction, options: TryOnBeforeMountOpti sync = true, target, } = options; - + const instance = getLifeCycleTarger(target); if (instance) @@ -43,4 +43,4 @@ export function tryOnBeforeMount(fn: VoidFunction, options: TryOnBeforeMountOpti fn(); else nextTick(fn); -} \ No newline at end of file +} diff --git a/vue/toolkit/src/composables/lifecycle/tryOnMounted/demo.vue b/vue/toolkit/src/composables/lifecycle/tryOnMounted/demo.vue new file mode 100644 index 0000000..ba787d6 --- /dev/null +++ b/vue/toolkit/src/composables/lifecycle/tryOnMounted/demo.vue @@ -0,0 +1,76 @@ + + + diff --git a/web/vue/src/composables/lifecycle/tryOnMounted/index.test.ts b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts similarity index 91% rename from web/vue/src/composables/lifecycle/tryOnMounted/index.test.ts rename to vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts index 4000a8b..507ef72 100644 --- a/web/vue/src/composables/lifecycle/tryOnMounted/index.test.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, vi, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { defineComponent, nextTick } from 'vue'; import type { PropType } from 'vue'; import { tryOnMounted } from '.'; @@ -12,7 +12,9 @@ const ComponentStub = defineComponent({ }, }, setup(props) { - if (props.callback) { tryOnMounted(props.callback); } + if (props.callback) { + tryOnMounted(props.callback); + } }, template: `

`, }); @@ -20,7 +22,7 @@ const ComponentStub = defineComponent({ describe(tryOnMounted, () => { it('run the callback when mounted', () => { const callback = vi.fn(); - + mount(ComponentStub, { props: { callback }, }); @@ -55,4 +57,4 @@ describe(tryOnMounted, () => { expect(callback).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/web/vue/src/composables/lifecycle/tryOnMounted/index.ts b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts similarity index 92% rename from web/vue/src/composables/lifecycle/tryOnMounted/index.ts rename to vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts index 583fca4..e9b133d 100644 --- a/web/vue/src/composables/lifecycle/tryOnMounted/index.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts @@ -1,10 +1,8 @@ -import { onMounted, nextTick } from 'vue'; +import { nextTick, onMounted } from 'vue'; import type { ComponentInternalInstance } from 'vue'; import { getLifeCycleTarger } from '@/utils'; import type { VoidFunction } from '@robonen/stdlib'; -// TODO: tests - export interface TryOnMountedOptions { sync?: boolean; target?: ComponentInternalInstance; @@ -14,19 +12,19 @@ export interface TryOnMountedOptions { * @name tryOnMounted * @category Lifecycle * @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 = {}) { @@ -37,10 +35,10 @@ export function tryOnMounted(fn: VoidFunction, options: TryOnMountedOptions = {} const instance = getLifeCycleTarger(target); - if (instance) + if (instance) onMounted(fn, instance); else if (sync) fn(); else nextTick(fn); -} \ No newline at end of file +} diff --git a/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/demo.vue b/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/demo.vue new file mode 100644 index 0000000..8ca903c --- /dev/null +++ b/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/demo.vue @@ -0,0 +1,137 @@ + + + diff --git a/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.test.ts b/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/index.test.ts similarity index 97% rename from web/vue/src/composables/lifecycle/tryOnScopeDispose/index.test.ts rename to vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/index.test.ts index 81c3884..25b8933 100644 --- a/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.test.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/index.test.ts @@ -9,8 +9,8 @@ const ComponentStub = defineComponent({ props: { callback: { type: Function as PropType, - required: true - } + required: true, + }, }, setup(props) { tryOnScopeDispose(props.callback); @@ -26,7 +26,7 @@ describe(tryOnScopeDispose, () => { expect(detectedScope).toBeFalsy(); expect(callback).not.toHaveBeenCalled(); }); - + it('run the callback when the scope is disposed', () => { const callback = vi.fn(); const scope = effectScope(); @@ -56,4 +56,4 @@ describe(tryOnScopeDispose, () => { expect(callback).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.ts b/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/index.ts similarity index 98% rename from web/vue/src/composables/lifecycle/tryOnScopeDispose/index.ts rename to vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/index.ts index 7439016..1be7833 100644 --- a/web/vue/src/composables/lifecycle/tryOnScopeDispose/index.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnScopeDispose/index.ts @@ -5,13 +5,13 @@ import { getCurrentScope, onScopeDispose } from 'vue'; * @name tryOnScopeDispose * @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. * @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) { diff --git a/vue/toolkit/src/composables/lifecycle/useMounted/demo.vue b/vue/toolkit/src/composables/lifecycle/useMounted/demo.vue new file mode 100644 index 0000000..620f057 --- /dev/null +++ b/vue/toolkit/src/composables/lifecycle/useMounted/demo.vue @@ -0,0 +1,50 @@ + + + diff --git a/vue/toolkit/src/composables/lifecycle/useMounted/index.test.ts b/vue/toolkit/src/composables/lifecycle/useMounted/index.test.ts new file mode 100644 index 0000000..c965c24 --- /dev/null +++ b/vue/toolkit/src/composables/lifecycle/useMounted/index.test.ts @@ -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: `
{{ isMounted }}
`, +}); + +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'); + }); +}); diff --git a/web/vue/src/composables/lifecycle/useMounted/index.ts b/vue/toolkit/src/composables/lifecycle/useMounted/index.ts similarity index 90% rename from web/vue/src/composables/lifecycle/useMounted/index.ts rename to vue/toolkit/src/composables/lifecycle/useMounted/index.ts index 397b1f0..46e83ae 100644 --- a/web/vue/src/composables/lifecycle/useMounted/index.ts +++ b/vue/toolkit/src/composables/lifecycle/useMounted/index.ts @@ -6,23 +6,25 @@ import { getLifeCycleTarger } from '@/utils'; * @name useMounted * @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 * @returns {Readonly>} 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); + onMounted(() => { + isMounted.value = true; + }, targetInstance); return readonly(isMounted); } diff --git a/vue/toolkit/src/composables/math/index.ts b/vue/toolkit/src/composables/math/index.ts new file mode 100644 index 0000000..28cf7d2 --- /dev/null +++ b/vue/toolkit/src/composables/math/index.ts @@ -0,0 +1,16 @@ +export * from './logicAnd'; +export * from './logicNot'; +export * from './logicOr'; +export * from './useAbs'; +export * from './useAverage'; +export * from './useCeil'; +export * from './useClamp'; +export * from './useFloor'; +export * from './useMath'; +export * from './useMax'; +export * from './useMin'; +export * from './usePrecision'; +export * from './useProjection'; +export * from './useRound'; +export * from './useSum'; +export * from './useTrunc'; diff --git a/vue/toolkit/src/composables/math/logicAnd/demo.vue b/vue/toolkit/src/composables/math/logicAnd/demo.vue new file mode 100644 index 0000000..e446500 --- /dev/null +++ b/vue/toolkit/src/composables/math/logicAnd/demo.vue @@ -0,0 +1,105 @@ + + + diff --git a/vue/toolkit/src/composables/math/logicAnd/index.test.ts b/vue/toolkit/src/composables/math/logicAnd/index.test.ts new file mode 100644 index 0000000..05eeb8d --- /dev/null +++ b/vue/toolkit/src/composables/math/logicAnd/index.test.ts @@ -0,0 +1,87 @@ +import { computed, isReadonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { and, logicAnd } from '.'; + +describe(logicAnd, () => { + it('returns true when every input is truthy', () => { + expect(logicAnd(true, true, true).value).toBeTruthy(); + expect(logicAnd(ref(true), () => true, true).value).toBeTruthy(); + }); + + it('returns false when any input is falsy', () => { + expect(logicAnd(true, false, true).value).toBeFalsy(); + expect(logicAnd(ref(true), ref(false)).value).toBeFalsy(); + }); + + it('returns true with no arguments (vacuous truth)', () => { + expect(logicAnd().value).toBeTruthy(); + }); + + it('reacts to ref changes', () => { + const a = ref(true); + const b = ref(true); + const result = logicAnd(a, b); + + expect(result.value).toBeTruthy(); + + b.value = false; + expect(result.value).toBeFalsy(); + + b.value = true; + a.value = false; + expect(result.value).toBeFalsy(); + }); + + it('supports getters', () => { + const flag = ref(true); + const result = logicAnd(() => flag.value, () => true); + + expect(result.value).toBeTruthy(); + + flag.value = false; + expect(result.value).toBeFalsy(); + }); + + it('works with computed inputs', () => { + const count = ref(2); + const positive = computed(() => count.value > 0); + const even = computed(() => count.value % 2 === 0); + const result = logicAnd(positive, even); + + expect(result.value).toBeTruthy(); + + count.value = 3; + expect(result.value).toBeFalsy(); + + count.value = -4; + expect(result.value).toBeFalsy(); + }); + + it('coerces non-boolean truthy and falsy values to a strict boolean result', () => { + const truthy = logicAnd(1, 'a', {}); + const falsy = logicAnd(1, 0); + + // The composable normalises to a real boolean rather than passing the + // raw value through, so assert on the exact value and its type. + expect(truthy.value).toBeTruthy(); + expect(typeof truthy.value).toBe('boolean'); + expect(falsy.value).toBeFalsy(); + expect(typeof falsy.value).toBe('boolean'); + + expect(logicAnd('text', '').value).toBeFalsy(); + expect(logicAnd(1, null).value).toBeFalsy(); + expect(logicAnd(1, undefined).value).toBeFalsy(); + }); + + it('returns a readonly computed', () => { + const result = logicAnd(ref(true)); + + expect(isReadonly(result)).toBeTruthy(); + }); + + it('exposes `and` as an alias of `logicAnd`', () => { + expect(and).toBe(logicAnd); + expect(and(ref(true), () => true).value).toBeTruthy(); + expect(and(ref(true), ref(false)).value).toBeFalsy(); + }); +}); diff --git a/vue/toolkit/src/composables/math/logicAnd/index.ts b/vue/toolkit/src/composables/math/logicAnd/index.ts new file mode 100644 index 0000000..77cc822 --- /dev/null +++ b/vue/toolkit/src/composables/math/logicAnd/index.ts @@ -0,0 +1,37 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type LogicAndReturn = ComputedRef; + +/** + * @name logicAnd + * @category Math + * @description Reactive logical `AND` across boolean refs or getters. The result is `true` only when every input resolves to a truthy value. + * + * @param {...MaybeRefOrGetter} args The boolean refs or getters to combine + * @returns {LogicAndReturn} A readonly computed that is `true` only when every input is truthy + * + * @example + * const a = ref(true); + * const b = ref(false); + * const all = logicAnd(a, b); + * // all.value === false + * + * @example + * const isReady = ref(true); + * const hasAccess = computed(() => true); + * const canProceed = logicAnd(isReady, hasAccess, () => true); + * // canProceed.value === true + * + * @since 0.0.15 + */ +export function logicAnd(...args: Array>): LogicAndReturn { + return computed(() => args.every(arg => Boolean(toValue(arg)))); +} + +/** + * Alias for {@link logicAnd}. + * + * @since 0.0.15 + */ +export const and: typeof logicAnd = logicAnd; diff --git a/vue/toolkit/src/composables/math/logicNot/demo.vue b/vue/toolkit/src/composables/math/logicNot/demo.vue new file mode 100644 index 0000000..6f8222a --- /dev/null +++ b/vue/toolkit/src/composables/math/logicNot/demo.vue @@ -0,0 +1,53 @@ + + + diff --git a/vue/toolkit/src/composables/math/logicNot/index.test.ts b/vue/toolkit/src/composables/math/logicNot/index.test.ts new file mode 100644 index 0000000..6dc31ad --- /dev/null +++ b/vue/toolkit/src/composables/math/logicNot/index.test.ts @@ -0,0 +1,76 @@ +import { computed, isReadonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { logicNot, not } from '.'; + +describe(logicNot, () => { + it('returns the negation of a truthy input', () => { + expect(logicNot(true).value).toBeFalsy(); + expect(logicNot(ref(true)).value).toBeFalsy(); + expect(logicNot(() => true).value).toBeFalsy(); + }); + + it('returns the negation of a falsy input', () => { + expect(logicNot(false).value).toBeTruthy(); + expect(logicNot(ref(false)).value).toBeTruthy(); + expect(logicNot(() => false).value).toBeTruthy(); + }); + + it('reacts to ref changes', () => { + const a = ref(true); + const result = logicNot(a); + + expect(result.value).toBeFalsy(); + + a.value = false; + expect(result.value).toBeTruthy(); + + a.value = true; + expect(result.value).toBeFalsy(); + }); + + it('supports getters', () => { + const flag = ref(false); + const result = logicNot(() => flag.value); + + expect(result.value).toBeTruthy(); + + flag.value = true; + expect(result.value).toBeFalsy(); + }); + + it('works with computed inputs', () => { + const count = ref(0); + const positive = computed(() => count.value > 0); + const result = logicNot(positive); + + expect(result.value).toBeTruthy(); + + count.value = 5; + expect(result.value).toBeFalsy(); + + count.value = -1; + expect(result.value).toBeTruthy(); + }); + + it('coerces non-boolean values to a boolean result', () => { + expect(logicNot(1).value).toBeFalsy(); + expect(logicNot('text').value).toBeFalsy(); + expect(logicNot({}).value).toBeFalsy(); + expect(logicNot(0).value).toBeTruthy(); + expect(logicNot('').value).toBeTruthy(); + expect(logicNot(null).value).toBeTruthy(); + expect(logicNot(undefined).value).toBeTruthy(); + }); + + it('returns a readonly computed', () => { + const result = logicNot(ref(true)); + + expect(isReadonly(result)).toBeTruthy(); + }); + + it('exposes `not` as an alias of `logicNot`', () => { + expect(not).toBe(logicNot); + expect(not(ref(true)).value).toBeFalsy(); + expect(not(() => false).value).toBeTruthy(); + }); +}); diff --git a/vue/toolkit/src/composables/math/logicNot/index.ts b/vue/toolkit/src/composables/math/logicNot/index.ts new file mode 100644 index 0000000..8f3faab --- /dev/null +++ b/vue/toolkit/src/composables/math/logicNot/index.ts @@ -0,0 +1,35 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type LogicNotReturn = ComputedRef; + +/** + * @name logicNot + * @category Math + * @description Reactive logical `NOT` of a boolean ref or getter. The result is `true` whenever the input resolves to a falsy value. + * + * @param {MaybeRefOrGetter} v The boolean ref or getter to negate + * @returns {LogicNotReturn} A readonly computed that is `true` only when the input is falsy + * + * @example + * const a = ref(true); + * const notA = logicNot(a); + * // notA.value === false + * + * @example + * const isLoading = ref(false); + * const isReady = logicNot(isLoading); + * // isReady.value === true + * + * @since 0.0.15 + */ +export function logicNot(v: MaybeRefOrGetter): LogicNotReturn { + return computed(() => !toValue(v)); +} + +/** + * Alias for {@link logicNot}. + * + * @since 0.0.15 + */ +export const not: typeof logicNot = logicNot; diff --git a/vue/toolkit/src/composables/math/logicOr/demo.vue b/vue/toolkit/src/composables/math/logicOr/demo.vue new file mode 100644 index 0000000..0804144 --- /dev/null +++ b/vue/toolkit/src/composables/math/logicOr/demo.vue @@ -0,0 +1,55 @@ + + + diff --git a/vue/toolkit/src/composables/math/logicOr/index.test.ts b/vue/toolkit/src/composables/math/logicOr/index.test.ts new file mode 100644 index 0000000..b3a0291 --- /dev/null +++ b/vue/toolkit/src/composables/math/logicOr/index.test.ts @@ -0,0 +1,98 @@ +import { computed, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { logicOr, or } from '.'; + +describe(logicOr, () => { + it('returns false with no arguments', () => { + const result = logicOr(); + + expect(result.value).toBeFalsy(); + }); + + it('returns true when a single raw value is truthy', () => { + expect(logicOr(true).value).toBeTruthy(); + expect(logicOr(1).value).toBeTruthy(); + }); + + it('returns false when a single raw value is falsy', () => { + expect(logicOr(false).value).toBeFalsy(); + expect(logicOr(0).value).toBeFalsy(); + }); + + it('returns true when at least one ref is truthy', () => { + const a = ref(false); + const b = ref(true); + const result = logicOr(a, b); + + expect(result.value).toBeTruthy(); + }); + + it('returns false when all refs are falsy', () => { + const a = ref(false); + const b = ref(false); + const result = logicOr(a, b); + + expect(result.value).toBeFalsy(); + }); + + it('supports getter arguments', () => { + const result = logicOr(() => false, () => true); + + expect(result.value).toBeTruthy(); + }); + + it('mixes refs, getters and raw values', () => { + const a = ref(false); + const result = logicOr(a, () => false, 1); + + expect(result.value).toBeTruthy(); + }); + + it('reacts to ref changes', () => { + const a = ref(false); + const b = ref(false); + const result = logicOr(a, b); + + expect(result.value).toBeFalsy(); + + a.value = true; + + expect(result.value).toBeTruthy(); + }); + + it('coerces truthy and falsy non-boolean values', () => { + expect(logicOr(ref('hello')).value).toBeTruthy(); + expect(logicOr(ref('')).value).toBeFalsy(); + expect(logicOr(ref(null)).value).toBeFalsy(); + expect(logicOr(ref(undefined)).value).toBeFalsy(); + }); + + it('handles computed sources', () => { + const count = ref(0); + const positive = computed(() => count.value > 0); + const result = logicOr(positive); + + expect(result.value).toBeFalsy(); + + count.value = 5; + + expect(result.value).toBeTruthy(); + }); + + it('exposes `or` as an alias of `logicOr`', () => { + expect(or).toBe(logicOr); + + const a = ref(false); + const b = ref(true); + + expect(or(a, b).value).toBeTruthy(); + }); + + it('is SSR-safe and touches no globals', () => { + // Pure reactive computation: constructing and reading must never reference + // window/document/navigator, so this works identically in any environment. + const result = logicOr(ref(false), () => false); + + expect(result.value).toBeFalsy(); + }); +}); diff --git a/vue/toolkit/src/composables/math/logicOr/index.ts b/vue/toolkit/src/composables/math/logicOr/index.ts new file mode 100644 index 0000000..e092cc3 --- /dev/null +++ b/vue/toolkit/src/composables/math/logicOr/index.ts @@ -0,0 +1,54 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type LogicOrSource = MaybeRefOrGetter; + +/** + * @name logicOr + * @category Math + * @description Reactively compute the logical `OR` across a list of boolean + * sources (each a ref, getter, or raw value). Returns a `ComputedRef` + * that is `true` when at least one source is truthy. Short-circuits on the first + * truthy source, so later refs are only read when needed. With no arguments the + * result is `false` (the identity for `OR`). Fully SSR-safe — touches no globals. + * + * @param {...MaybeRefOrGetter} args The boolean sources to combine + * @returns {ComputedRef} A computed ref that is `true` when any source is truthy + * + * @example + * const a = ref(false); + * const b = ref(true); + * const either = logicOr(a, b); // true + * + * @example + * const valid = ref(false); + * const result = logicOr(valid, () => Date.now() > 0); // true + * + * @since 0.0.15 + */ +export function logicOr(...args: LogicOrSource[]): ComputedRef { + return computed(() => { + // Manual loop short-circuits on the first truthy source without allocating + // a closure per evaluation the way Array.prototype.some would. + for (const arg of args) + if (toValue(arg)) return true; + + return false; + }); +} + +/** + * @name or + * @category Math + * @description Alias for {@link logicOr}. Reactively computes the logical `OR` + * across boolean refs, getters, or raw values. + * + * @param {...MaybeRefOrGetter} args The boolean sources to combine + * @returns {ComputedRef} A computed ref that is `true` when any source is truthy + * + * @example + * const result = or(a, b); + * + * @since 0.0.15 + */ +export const or: typeof logicOr = logicOr; diff --git a/vue/toolkit/src/composables/math/useAbs/demo.vue b/vue/toolkit/src/composables/math/useAbs/demo.vue new file mode 100644 index 0000000..7697e17 --- /dev/null +++ b/vue/toolkit/src/composables/math/useAbs/demo.vue @@ -0,0 +1,52 @@ + + + diff --git a/vue/toolkit/src/composables/math/useAbs/index.test.ts b/vue/toolkit/src/composables/math/useAbs/index.test.ts new file mode 100644 index 0000000..1b4c8e3 --- /dev/null +++ b/vue/toolkit/src/composables/math/useAbs/index.test.ts @@ -0,0 +1,64 @@ +import { computed, isReadonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useAbs } from '.'; + +describe(useAbs, () => { + it('returns the absolute value of a static number', () => { + expect(useAbs(-42).value).toBe(42); + expect(useAbs(42).value).toBe(42); + expect(useAbs(0).value).toBe(0); + }); + + it('returns the absolute value of a ref', () => { + const value = ref(-10); + const abs = useAbs(value); + + expect(abs.value).toBe(10); + }); + + it('reacts to ref changes', () => { + const value = ref(-10); + const abs = useAbs(value); + + expect(abs.value).toBe(10); + + value.value = 5; + expect(abs.value).toBe(5); + + value.value = -3; + expect(abs.value).toBe(3); + }); + + it('supports getters', () => { + const value = ref(-7); + const abs = useAbs(() => value.value); + + expect(abs.value).toBe(7); + + value.value = -100; + expect(abs.value).toBe(100); + }); + + it('handles negative zero, infinity and NaN like Math.abs', () => { + expect(useAbs(-0).value).toBe(0); + expect(useAbs(Number.NEGATIVE_INFINITY).value).toBe(Number.POSITIVE_INFINITY); + expect(useAbs(Number.NaN).value).toBeNaN(); + }); + + it('returns a readonly computed', () => { + const abs = useAbs(ref(-1)); + + expect(isReadonly(abs)).toBeTruthy(); + }); + + it('works with computed inputs', () => { + const value = ref(-4); + const doubled = computed(() => value.value * 2); + const abs = useAbs(doubled); + + expect(abs.value).toBe(8); + + value.value = 3; + expect(abs.value).toBe(6); + }); +}); diff --git a/vue/toolkit/src/composables/math/useAbs/index.ts b/vue/toolkit/src/composables/math/useAbs/index.ts new file mode 100644 index 0000000..c0ea5c7 --- /dev/null +++ b/vue/toolkit/src/composables/math/useAbs/index.ts @@ -0,0 +1,27 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseAbsReturn = ComputedRef; + +/** + * @name useAbs + * @category Math + * @description Reactive `Math.abs` of a number ref or getter + * + * @param {MaybeRefOrGetter} value The value to take the absolute value of + * @returns {UseAbsReturn} A readonly computed of `Math.abs(value)` + * + * @example + * const value = ref(-42); + * const abs = useAbs(value); + * // abs.value === 42 + * + * @example + * const abs = useAbs(() => -10); + * // abs.value === 10 + * + * @since 0.0.15 + */ +export function useAbs(value: MaybeRefOrGetter): UseAbsReturn { + return computed(() => Math.abs(toValue(value))); +} diff --git a/vue/toolkit/src/composables/math/useAverage/demo.vue b/vue/toolkit/src/composables/math/useAverage/demo.vue new file mode 100644 index 0000000..55be5af --- /dev/null +++ b/vue/toolkit/src/composables/math/useAverage/demo.vue @@ -0,0 +1,71 @@ + + + diff --git a/vue/toolkit/src/composables/math/useAverage/index.test.ts b/vue/toolkit/src/composables/math/useAverage/index.test.ts new file mode 100644 index 0000000..55e0351 --- /dev/null +++ b/vue/toolkit/src/composables/math/useAverage/index.test.ts @@ -0,0 +1,116 @@ +import { computed, readonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useAverage } from '.'; + +describe(useAverage, () => { + it('returns the average of raw variadic values', () => { + const avg = useAverage(1, 3, 2); + + expect(avg.value).toBe(2); + }); + + it('returns the average of ref variadic values', () => { + const a = ref(2); + const b = ref(4); + const c = ref(6); + const avg = useAverage(a, b, c); + + expect(avg.value).toBe(4); + }); + + it('supports getter arguments', () => { + const avg = useAverage(() => 4, () => 8, () => 0); + + expect(avg.value).toBe(4); + }); + + it('mixes refs, getters and raw values', () => { + const a = ref(5); + const avg = useAverage(a, () => 7, 3); + + expect(avg.value).toBe(5); + }); + + it('reacts to ref changes', () => { + const a = ref(2); + const b = ref(4); + const avg = useAverage(a, b); + + expect(avg.value).toBe(3); + + a.value = 10; + + expect(avg.value).toBe(7); + }); + + it('returns the average of a reactive array', () => { + const list = ref([1, 5, 3]); + const avg = useAverage(list); + + expect(avg.value).toBe(3); + + list.value = [2, 4, 6]; + + expect(avg.value).toBe(4); + }); + + it('returns the average of a getter array', () => { + const avg = useAverage(() => [1, 2, 3, 4]); + + expect(avg.value).toBe(2.5); + }); + + it('unwraps refs and getters nested inside the array', () => { + const a = ref(2); + const avg = useAverage(ref([a, () => 4, 6])); + + expect(avg.value).toBe(4); + + a.value = 8; + + expect(avg.value).toBe(6); + }); + + it('reacts when a mutated plain array ref is reassigned', () => { + const list = ref([1, 2, 3]); + const avg = useAverage(list); + + expect(avg.value).toBe(2); + + list.value = [...list.value, 6]; + + expect(avg.value).toBe(3); + }); + + it('handles readonly and computed sources', () => { + const computedValue = computed(() => 12); + const readonlyValue = readonly(ref(6)); + const avg = useAverage(computedValue, readonlyValue, 3); + + expect(avg.value).toBe(7); + }); + + it('handles negative values', () => { + const avg = useAverage(-4, -2, -6); + + expect(avg.value).toBe(-4); + }); + + it('returns NaN for an empty array (SSR-safe, no globals touched)', () => { + const avg = useAverage(ref([])); + + expect(avg.value).toBeNaN(); + }); + + it('returns the single value when only one argument is given', () => { + const avg = useAverage(42); + + expect(avg.value).toBe(42); + }); + + it('returns the mean of a single-element array', () => { + const avg = useAverage(ref([9])); + + expect(avg.value).toBe(9); + }); +}); diff --git a/vue/toolkit/src/composables/math/useAverage/index.ts b/vue/toolkit/src/composables/math/useAverage/index.ts new file mode 100644 index 0000000..e78e844 --- /dev/null +++ b/vue/toolkit/src/composables/math/useAverage/index.ts @@ -0,0 +1,55 @@ +import { isArray, sum } from '@robonen/stdlib'; +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import type { MaybeComputedRefArgs } from '@/types'; + +/** + * @name useAverage + * @category Math + * @description Reactively compute the average (arithmetic mean) of the provided + * numbers. Accepts either a variadic list of numbers (each a ref, getter, or raw + * value) or a single reactive array whose items may themselves be refs/getters. + * Returns `NaN` when there are no values, mirroring `0 / 0`. + * + * @param {...MaybeRefOrGetter} args The values to average, or a single reactive array of values + * @returns {ComputedRef} A computed ref of the mean (`NaN` when empty) + * + * @example + * const a = ref(1); + * const b = ref(3); + * const avg = useAverage(a, b, 2); // 2 + * + * @example + * const list = ref([1, 5, 3]); + * const avg = useAverage(list); // 3 + * + * @example + * const list = ref([ref(2), () => 4, 6]); + * const avg = useAverage(list); // 4 + * + * @since 0.0.15 + */ +export function useAverage(array: MaybeRefOrGetter>>): ComputedRef; +export function useAverage(...args: Array>): ComputedRef; +export function useAverage(...args: MaybeComputedRefArgs): ComputedRef { + return computed(() => { + // Collect into a single flat numeric array so we can reuse stdlib `sum` + // and divide by the real count in one pass — no intermediate flatMap. + const values: number[] = []; + + for (const arg of args) { + const value = toValue(arg); + + if (isArray(value)) { + for (const inner of value) + values.push(toValue(inner)); + } + else { + values.push(value); + } + } + + // Empty -> NaN (0 / 0), matching the mathematical definition of a mean. + return sum(values) / values.length; + }); +} diff --git a/vue/toolkit/src/composables/math/useCeil/demo.vue b/vue/toolkit/src/composables/math/useCeil/demo.vue new file mode 100644 index 0000000..9424a22 --- /dev/null +++ b/vue/toolkit/src/composables/math/useCeil/demo.vue @@ -0,0 +1,66 @@ + + + diff --git a/vue/toolkit/src/composables/math/useCeil/index.test.ts b/vue/toolkit/src/composables/math/useCeil/index.test.ts new file mode 100644 index 0000000..73de748 --- /dev/null +++ b/vue/toolkit/src/composables/math/useCeil/index.test.ts @@ -0,0 +1,56 @@ +import { computed, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useCeil } from '.'; + +describe(useCeil, () => { + it('rounds up a non-reactive value', () => { + const ceiled = useCeil(0.95); + + expect(ceiled.value).toBe(1); + }); + + it('rounds up a ref value', () => { + const value = ref(7.004); + const ceiled = useCeil(value); + + expect(ceiled.value).toBe(8); + }); + + it('rounds up a getter value', () => { + const ceiled = useCeil(() => 4.0001); + + expect(ceiled.value).toBe(5); + }); + + it('rounds up a computed value', () => { + const value = computed(() => -0.5); + const ceiled = useCeil(value); + + expect(ceiled.value).toBe(-0); + }); + + it('reacts to changes in the source ref', () => { + const value = ref(1.2); + const ceiled = useCeil(value); + + expect(ceiled.value).toBe(2); + + value.value = 9.9; + expect(ceiled.value).toBe(10); + + value.value = -3.7; + expect(ceiled.value).toBe(-3); + }); + + it('passes integers through unchanged', () => { + expect(useCeil(5).value).toBe(5); + expect(useCeil(-5).value).toBe(-5); + expect(useCeil(0).value).toBe(0); + }); + + it('propagates non-finite inputs', () => { + expect(useCeil(Infinity).value).toBe(Infinity); + expect(useCeil(-Infinity).value).toBe(-Infinity); + expect(useCeil(Number.NaN).value).toBeNaN(); + }); +}); diff --git a/vue/toolkit/src/composables/math/useCeil/index.ts b/vue/toolkit/src/composables/math/useCeil/index.ts new file mode 100644 index 0000000..322f510 --- /dev/null +++ b/vue/toolkit/src/composables/math/useCeil/index.ts @@ -0,0 +1,25 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseCeilReturn = ComputedRef; + +/** + * @name useCeil + * @category Math + * @description Reactive `Math.ceil`. Rounds a number up to the next largest integer. + * + * @param {MaybeRefOrGetter} value The value to round up + * @returns {UseCeilReturn} A readonly computed ref of the rounded-up value + * + * @example + * const value = ref(0.95); + * const ceiled = useCeil(value); // 1 + * + * @example + * const ceiled = useCeil(() => 7.004); // 8 + * + * @since 0.0.15 + */ +export function useCeil(value: MaybeRefOrGetter): UseCeilReturn { + return computed(() => Math.ceil(toValue(value))); +} diff --git a/vue/toolkit/src/composables/math/useClamp/demo.vue b/vue/toolkit/src/composables/math/useClamp/demo.vue new file mode 100644 index 0000000..91313b3 --- /dev/null +++ b/vue/toolkit/src/composables/math/useClamp/demo.vue @@ -0,0 +1,91 @@ + + + diff --git a/web/vue/src/composables/math/useClamp/index.test.ts b/vue/toolkit/src/composables/math/useClamp/index.test.ts similarity index 93% rename from web/vue/src/composables/math/useClamp/index.test.ts rename to vue/toolkit/src/composables/math/useClamp/index.test.ts index ec3bf6f..b334438 100644 --- a/web/vue/src/composables/math/useClamp/index.test.ts +++ b/vue/toolkit/src/composables/math/useClamp/index.test.ts @@ -1,5 +1,5 @@ -import { ref, readonly, computed } from 'vue'; -import { describe, it, expect } from 'vitest'; +import { computed, readonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; import { useClamp } from '.'; describe(useClamp, () => { @@ -57,4 +57,4 @@ describe(useClamp, () => { expect(clampedValue.value).toBe(11); }); -}); \ No newline at end of file +}); diff --git a/web/vue/src/composables/math/useClamp/index.ts b/vue/toolkit/src/composables/math/useClamp/index.ts similarity index 98% rename from web/vue/src/composables/math/useClamp/index.ts rename to vue/toolkit/src/composables/math/useClamp/index.ts index 91577e1..b054885 100644 --- a/web/vue/src/composables/math/useClamp/index.ts +++ b/vue/toolkit/src/composables/math/useClamp/index.ts @@ -6,20 +6,20 @@ import type { ComputedRef, MaybeRef, MaybeRefOrGetter, WritableComputedRef } fro * @name useClamp * @category Math * @description Clamps a value between a minimum and maximum value - * + * * @param {MaybeRefOrGetter} value The value to clamp * @param {MaybeRefOrGetter} min The minimum value * @param {MaybeRefOrGetter} max The maximum value * @returns {ComputedRef} 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, min: MaybeRefOrGetter, max: MaybeRefOrGetter): WritableComputedRef; diff --git a/vue/toolkit/src/composables/math/useFloor/demo.vue b/vue/toolkit/src/composables/math/useFloor/demo.vue new file mode 100644 index 0000000..2d4cac1 --- /dev/null +++ b/vue/toolkit/src/composables/math/useFloor/demo.vue @@ -0,0 +1,67 @@ + + + diff --git a/vue/toolkit/src/composables/math/useFloor/index.test.ts b/vue/toolkit/src/composables/math/useFloor/index.test.ts new file mode 100644 index 0000000..ed69045 --- /dev/null +++ b/vue/toolkit/src/composables/math/useFloor/index.test.ts @@ -0,0 +1,63 @@ +import { computed, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useFloor } from '.'; + +describe(useFloor, () => { + it('floors a non-reactive value', () => { + const floored = useFloor(5.95); + + expect(floored.value).toBe(5); + }); + + it('floors a reactive ref value', () => { + const value = ref(5.05); + const floored = useFloor(value); + + expect(floored.value).toBe(5); + }); + + it('floors a getter value', () => { + const floored = useFloor(() => 5.5); + + expect(floored.value).toBe(5); + }); + + it('updates when the source ref changes', () => { + const value = ref(5.95); + const floored = useFloor(value); + + expect(floored.value).toBe(5); + + value.value = 8.1; + + expect(floored.value).toBe(8); + }); + + it('floors negative values toward negative infinity', () => { + expect(useFloor(-5.05).value).toBe(-6); + expect(useFloor(-0.5).value).toBe(-1); + }); + + it('returns integers unchanged', () => { + expect(useFloor(42).value).toBe(42); + expect(useFloor(0).value).toBe(0); + }); + + it('works with a computed source', () => { + const value = ref(2); + const source = computed(() => value.value + 0.9); + const floored = useFloor(source); + + expect(floored.value).toBe(2); + + value.value = 5; + + expect(floored.value).toBe(5); + }); + + it('propagates special numeric values', () => { + expect(useFloor(Number.NaN).value).toBeNaN(); + expect(useFloor(Infinity).value).toBe(Infinity); + expect(useFloor(-Infinity).value).toBe(-Infinity); + }); +}); diff --git a/vue/toolkit/src/composables/math/useFloor/index.ts b/vue/toolkit/src/composables/math/useFloor/index.ts new file mode 100644 index 0000000..3ea2717 --- /dev/null +++ b/vue/toolkit/src/composables/math/useFloor/index.ts @@ -0,0 +1,23 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * @name useFloor + * @category Math + * @description Reactive `Math.floor`. Returns the largest integer less than or equal to the given value + * + * @param {MaybeRefOrGetter} value The value to floor + * @returns {ComputedRef} The floored value + * + * @example + * const value = ref(5.95); + * const floored = useFloor(value); // 5 + * + * @example + * const floored = useFloor(() => 5.05); // 5 + * + * @since 0.0.15 + */ +export function useFloor(value: MaybeRefOrGetter): ComputedRef { + return computed(() => Math.floor(toValue(value))); +} diff --git a/vue/toolkit/src/composables/math/useMath/demo.vue b/vue/toolkit/src/composables/math/useMath/demo.vue new file mode 100644 index 0000000..927e89c --- /dev/null +++ b/vue/toolkit/src/composables/math/useMath/demo.vue @@ -0,0 +1,76 @@ + + + diff --git a/vue/toolkit/src/composables/math/useMath/index.test.ts b/vue/toolkit/src/composables/math/useMath/index.test.ts new file mode 100644 index 0000000..34227e2 --- /dev/null +++ b/vue/toolkit/src/composables/math/useMath/index.test.ts @@ -0,0 +1,113 @@ +import { computed, effectScope, ref } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; +import { useMath } from '.'; + +describe(useMath, () => { + it('computes a unary method from plain values', () => { + const result = useMath('abs', -5); + + expect(result.value).toBe(5); + }); + + it('computes a binary method from refs', () => { + const a = ref(2); + const b = ref(8); + const result = useMath('max', a, b); + + expect(result.value).toBe(8); + }); + + it('accepts getters as arguments', () => { + const value = ref(-4.7); + const result = useMath('round', () => value.value); + + expect(result.value).toBe(-5); + }); + + it('mixes plain values, refs and getters', () => { + const a = ref(3); + const result = useMath('max', a, 4, () => 1); + + expect(result.value).toBe(4); + }); + + it('handles variadic methods such as hypot', () => { + const x = ref(3); + const y = ref(4); + const result = useMath('hypot', x, y); + + expect(result.value).toBe(5); + }); + + it('recomputes when a ref argument changes', () => { + const a = ref(2); + const b = ref(8); + const result = useMath('max', a, b); + + expect(result.value).toBe(8); + + a.value = 20; + + expect(result.value).toBe(20); + + b.value = 100; + + expect(result.value).toBe(100); + }); + + it('recomputes when a getter dependency changes', () => { + const value = ref(1.4); + const result = useMath('round', () => value.value); + + expect(result.value).toBe(1); + + value.value = 1.6; + + expect(result.value).toBe(2); + }); + + it('works with readonly computed inputs', () => { + const value = computed(() => 2); + const result = useMath('pow', value, 3); + + expect(result.value).toBe(8); + }); + + it('returns a lazily evaluated computed ref', () => { + const spy = vi.spyOn(Math, 'sqrt'); + const value = ref(16); + const result = useMath('sqrt', value); + + // computed is lazy: not evaluated until first read + expect(spy).not.toHaveBeenCalled(); + + expect(result.value).toBe(4); + expect(spy).toHaveBeenCalledTimes(1); + + // repeated reads without dependency change are cached + void result.value; + expect(spy).toHaveBeenCalledTimes(1); + + spy.mockRestore(); + }); + + it('is SSR-safe (no global access; works inside a detached scope)', () => { + const scope = effectScope(); + let result: ReturnType> | undefined; + + scope.run(() => { + result = useMath('min', () => 10, () => 3, () => 7); + }); + + expect(result?.value).toBe(3); + + scope.stop(); + }); + + it('produces NaN for invalid input without throwing', () => { + const value = ref(-1); + const result = useMath('sqrt', value); + + expect(Number.isNaN(result.value)).toBeTruthy(); + }); +}); diff --git a/vue/toolkit/src/composables/math/useMath/index.ts b/vue/toolkit/src/composables/math/useMath/index.ts new file mode 100644 index 0000000..4b0a790 --- /dev/null +++ b/vue/toolkit/src/composables/math/useMath/index.ts @@ -0,0 +1,62 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * Keys of `Math` that resolve to callable methods (excludes numeric constants + * such as `Math.PI` or `Math.E`). + */ +export type UseMathKey + = keyof { [K in keyof Math as Math[K] extends (...args: any[]) => any ? K : never]: unknown }; + +/** + * Maps each argument of a `Math` method to a reactive equivalent + * (`MaybeRefOrGetter`), so callers may pass plain values, refs or getters. + */ +export type UseMathArgs + = Math[K] extends (...args: infer A) => any + ? { [I in keyof A]: MaybeRefOrGetter } + : never; + +/** + * Reactive result of the wrapped `Math` method. + */ +export type UseMathReturn + = Math[K] extends (...args: any[]) => infer R ? ComputedRef : never; + +/** + * @name useMath + * @category Math + * @description Reactive wrapper over any callable `Math.` method. Each + * argument may be a plain value, a ref or a getter; the result recomputes + * lazily whenever a reactive input changes. + * + * @param {UseMathKey} key The name of the `Math` method to wrap (e.g. `'max'`, `'round'`, `'hypot'`). + * @param {...UseMathArgs} args The reactive arguments forwarded to the method. + * @returns {UseMathReturn} A computed ref holding the method's result. + * + * @example + * const a = ref(2); + * const b = ref(8); + * const max = useMath('max', a, b); // ComputedRef -> 8 + * + * @example + * // getters and plain values mix freely + * const value = ref(-4.7); + * const rounded = useMath('round', value); // 5 -> -5 + * const absVal = useMath('abs', () => value.value); // 4.7 + * + * @example + * // variadic methods + * const sides = ref([3, 4]); + * const dist = useMath('hypot', () => sides.value[0], () => sides.value[1]); // 5 + * + * @since 0.0.15 + */ +export function useMath( + key: K, + ...args: UseMathArgs +): UseMathReturn { + return computed( + () => (Math[key] as (...a: any[]) => any)(...args.map(arg => toValue(arg))), + ) as UseMathReturn; +} diff --git a/vue/toolkit/src/composables/math/useMax/demo.vue b/vue/toolkit/src/composables/math/useMax/demo.vue new file mode 100644 index 0000000..a500cb4 --- /dev/null +++ b/vue/toolkit/src/composables/math/useMax/demo.vue @@ -0,0 +1,89 @@ + + + diff --git a/vue/toolkit/src/composables/math/useMax/index.test.ts b/vue/toolkit/src/composables/math/useMax/index.test.ts new file mode 100644 index 0000000..c6903bc --- /dev/null +++ b/vue/toolkit/src/composables/math/useMax/index.test.ts @@ -0,0 +1,113 @@ +import { computed, readonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useMax } from '.'; + +describe(useMax, () => { + it('returns the maximum of raw variadic values', () => { + const max = useMax(1, 3, 2); + + expect(max.value).toBe(3); + }); + + it('returns the maximum of ref variadic values', () => { + const a = ref(1); + const b = ref(3); + const c = ref(2); + const max = useMax(a, b, c); + + expect(max.value).toBe(3); + }); + + it('supports getter arguments', () => { + const max = useMax(() => 4, () => 9, () => 1); + + expect(max.value).toBe(9); + }); + + it('mixes refs, getters and raw values', () => { + const a = ref(5); + const max = useMax(a, () => 8, 2); + + expect(max.value).toBe(8); + }); + + it('reacts to ref changes', () => { + const a = ref(1); + const b = ref(3); + const max = useMax(a, b); + + expect(max.value).toBe(3); + + a.value = 10; + + expect(max.value).toBe(10); + }); + + it('returns the maximum of a reactive array', () => { + const list = ref([1, 5, 2]); + const max = useMax(list); + + expect(max.value).toBe(5); + + list.value = [7, 3, 4]; + + expect(max.value).toBe(7); + }); + + it('returns the maximum of a getter array', () => { + const max = useMax(() => [1, 9, 4]); + + expect(max.value).toBe(9); + }); + + it('unwraps refs and getters nested inside the array', () => { + const list = ref number)>>([ref(1) as unknown as number, () => 5, 2]); + // The array items are refs/getters; useMax should unwrap them. + const a = ref(1); + const max = useMax(ref([a, () => 5, 2])); + + expect(max.value).toBe(5); + + a.value = 99; + + expect(max.value).toBe(99); + void list; + }); + + it('reacts when a mutated plain array ref is reassigned', () => { + const list = ref([1, 2, 3]); + const max = useMax(list); + + expect(max.value).toBe(3); + + list.value = [...list.value, 100]; + + expect(max.value).toBe(100); + }); + + it('handles readonly and computed sources', () => { + const computedValue = computed(() => 12); + const readonlyValue = readonly(ref(7)); + const max = useMax(computedValue, readonlyValue, 3); + + expect(max.value).toBe(12); + }); + + it('handles negative values', () => { + const max = useMax(-5, -1, -10); + + expect(max.value).toBe(-1); + }); + + it('returns -Infinity for an empty array (SSR-safe, no globals touched)', () => { + const max = useMax(ref([])); + + expect(max.value).toBe(Number.NEGATIVE_INFINITY); + }); + + it('returns a single value when only one argument is given', () => { + const max = useMax(42); + + expect(max.value).toBe(42); + }); +}); diff --git a/vue/toolkit/src/composables/math/useMax/index.ts b/vue/toolkit/src/composables/math/useMax/index.ts new file mode 100644 index 0000000..2449583 --- /dev/null +++ b/vue/toolkit/src/composables/math/useMax/index.ts @@ -0,0 +1,55 @@ +import { isArray } from '@robonen/stdlib'; +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import type { MaybeComputedRefArgs } from '@/types'; + +/** + * @name useMax + * @category Math + * @description Reactively compute the maximum of the provided numbers. Accepts + * either a variadic list of numbers (each a ref, getter, or raw value) or a + * single reactive array whose items may themselves be refs/getters. + * + * @param {...MaybeRefOrGetter} args The values to compare, or a single reactive array of values + * @returns {ComputedRef} A computed ref of the largest value (`-Infinity` when empty) + * + * @example + * const a = ref(1); + * const b = ref(3); + * const max = useMax(a, b, 2); // 3 + * + * @example + * const list = ref([1, 5, 2]); + * const max = useMax(list); // 5 + * + * @example + * const list = ref([ref(1), () => 5, 2]); + * const max = useMax(list); // 5 + * + * @since 0.0.15 + */ +export function useMax(array: MaybeRefOrGetter>>): ComputedRef; +export function useMax(...args: Array>): ComputedRef; +export function useMax(...args: MaybeComputedRefArgs): ComputedRef { + return computed(() => { + // Avoid Math.max(...array): large spreads can overflow the call stack, and + // a single pass skips the intermediate flattened array that flatMap builds. + let max = Number.NEGATIVE_INFINITY; + + for (const arg of args) { + const value = toValue(arg); + + if (isArray(value)) { + for (const item of value) { + const inner = toValue(item); + if (inner > max) max = inner; + } + } + else if (value > max) { + max = value; + } + } + + return max; + }); +} diff --git a/vue/toolkit/src/composables/math/useMin/demo.vue b/vue/toolkit/src/composables/math/useMin/demo.vue new file mode 100644 index 0000000..a4616c3 --- /dev/null +++ b/vue/toolkit/src/composables/math/useMin/demo.vue @@ -0,0 +1,89 @@ + + + diff --git a/vue/toolkit/src/composables/math/useMin/index.test.ts b/vue/toolkit/src/composables/math/useMin/index.test.ts new file mode 100644 index 0000000..2b622ad --- /dev/null +++ b/vue/toolkit/src/composables/math/useMin/index.test.ts @@ -0,0 +1,89 @@ +import { computed, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useMin } from '.'; + +describe(useMin, () => { + it('returns the smallest of plain variadic values', () => { + const min = useMin(3, 1, 2); + + expect(min.value).toBe(1); + }); + + it('returns the smallest of refs/getters/values', () => { + const a = ref(2); + const b = ref(5); + const min = useMin(a, b, 10, () => -1); + + expect(min.value).toBe(-1); + }); + + it('reacts when a ref argument changes', () => { + const a = ref(2); + const b = ref(5); + const min = useMin(a, b); + + expect(min.value).toBe(2); + + a.value = 8; + + expect(min.value).toBe(5); + }); + + it('accepts a single plain array', () => { + const min = useMin([4, 2, 9]); + + expect(min.value).toBe(2); + }); + + it('accepts a reactive array whose items may be refs/getters', () => { + const item = ref(5); + const list = ref([10, item, () => 7]); + const min = useMin(list); + + expect(min.value).toBe(5); + + item.value = 1; + + expect(min.value).toBe(1); + }); + + it('reacts when the reactive array reference changes', () => { + const list = ref([4, 6]); + const min = useMin(list); + + expect(min.value).toBe(4); + + list.value = [9, 3, 8]; + + expect(min.value).toBe(3); + }); + + it('works with a getter that returns an array', () => { + const a = ref(8); + const min = useMin(() => [a.value, 3]); + + expect(min.value).toBe(3); + + a.value = 1; + + expect(min.value).toBe(1); + }); + + it('supports computed inputs', () => { + const base = ref(10); + const derived = computed(() => base.value * 2); + const min = useMin(derived, 15); + + expect(min.value).toBe(15); + + base.value = 4; + + expect(min.value).toBe(8); + }); + + it('returns Infinity for an empty array (matching Math.min)', () => { + const min = useMin([]); + + expect(min.value).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/vue/toolkit/src/composables/math/useMin/index.ts b/vue/toolkit/src/composables/math/useMin/index.ts new file mode 100644 index 0000000..7c05ff7 --- /dev/null +++ b/vue/toolkit/src/composables/math/useMin/index.ts @@ -0,0 +1,52 @@ +import { isArray } from '@robonen/stdlib'; +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type MaybeRefOrGetterArgs + = Array> | [MaybeRefOrGetter>>]; + +/** + * Resolve a variadic args tuple (numbers, refs/getters, or a single reactive + * array of refs/getters) into a flat array of resolved values. + */ +function resolveArgs(args: MaybeRefOrGetterArgs): number[] { + // Fast path: single reactive-array argument (the common useMin(arrayRef) case). + if (args.length === 1) { + const value = toValue(args[0] as MaybeRefOrGetter>>); + + if (isArray(value)) + return value.map(item => toValue(item)); + + return [value]; + } + + // Variadic path: each argument is a single number/ref/getter. + return (args as Array>).map(arg => toValue(arg)); +} + +/** + * @name useMin + * @category Math + * @description Reactive `Math.min`. Accepts a variadic list of numbers (each a + * ref, getter, or plain value) or a single reactive array whose items may + * themselves be refs/getters. + * + * @param {...MaybeRefOrGetter} args A list of numeric refs/getters/values, or a single reactive array of them + * @returns {ComputedRef} A computed of the smallest resolved value (`Infinity` when empty, matching `Math.min`) + * + * @example + * const a = ref(2); + * const b = ref(5); + * const min = useMin(a, b, 10); // 2 + * + * @example + * const list = ref([2, ref(5), () => 10]); + * const min = useMin(list); // 2 + * + * @since 0.0.15 + */ +export function useMin(array: MaybeRefOrGetter>>): ComputedRef; +export function useMin(...args: Array>): ComputedRef; +export function useMin(...args: MaybeRefOrGetterArgs): ComputedRef { + return computed(() => Math.min(...resolveArgs(args))); +} diff --git a/vue/toolkit/src/composables/math/usePrecision/demo.vue b/vue/toolkit/src/composables/math/usePrecision/demo.vue new file mode 100644 index 0000000..f5b4868 --- /dev/null +++ b/vue/toolkit/src/composables/math/usePrecision/demo.vue @@ -0,0 +1,101 @@ + + + diff --git a/vue/toolkit/src/composables/math/usePrecision/index.test.ts b/vue/toolkit/src/composables/math/usePrecision/index.test.ts new file mode 100644 index 0000000..ad1d382 --- /dev/null +++ b/vue/toolkit/src/composables/math/usePrecision/index.test.ts @@ -0,0 +1,103 @@ +import { ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { usePrecision } from '.'; + +describe(usePrecision, () => { + it('rounds to the requested number of digits by default', () => { + const result = usePrecision(3.14159, 2); + + expect(result.value).toBe(3.14); + }); + + it('works with plain (non-reactive) inputs', () => { + expect(usePrecision(1.2345, 0).value).toBe(1); + expect(usePrecision(1.5, 0).value).toBe(2); + }); + + it('reacts when the source value changes', () => { + const value = ref(3.14159); + const result = usePrecision(value, 2); + + expect(result.value).toBe(3.14); + + value.value = 9.87654; + + expect(result.value).toBe(9.88); + }); + + it('reacts when the digits change', () => { + const value = ref(3.14159); + const digits = ref(2); + const result = usePrecision(value, digits); + + expect(result.value).toBe(3.14); + + digits.value = 4; + + expect(result.value).toBe(3.1416); + + digits.value = 0; + + expect(result.value).toBe(3); + }); + + it('supports getter inputs', () => { + const result = usePrecision(() => 3.14159, () => 3); + + expect(result.value).toBe(3.142); + }); + + it('applies the floor math option', () => { + const result = usePrecision(3.14159, 2, { math: 'floor' }); + + expect(result.value).toBe(3.14); + expect(usePrecision(3.149, 2, { math: 'floor' }).value).toBe(3.14); + }); + + it('applies the ceil math option', () => { + const result = usePrecision(3.14159, 2, { math: 'ceil' }); + + expect(result.value).toBe(3.15); + expect(usePrecision(3.141, 2, { math: 'ceil' }).value).toBe(3.15); + }); + + it('reacts when the options change', () => { + const options = ref<{ math: 'floor' | 'ceil' | 'round' }>({ math: 'floor' }); + const result = usePrecision(3.149, 2, options); + + expect(result.value).toBe(3.14); + + options.value = { math: 'ceil' }; + + expect(result.value).toBe(3.15); + }); + + it('supports getter options', () => { + const result = usePrecision(3.149, 2, () => ({ math: 'ceil' })); + + expect(result.value).toBe(3.15); + }); + + it('handles negative numbers', () => { + expect(usePrecision(-3.14159, 2).value).toBe(-3.14); + expect(usePrecision(-3.149, 2, { math: 'floor' }).value).toBe(-3.15); + expect(usePrecision(-3.141, 2, { math: 'ceil' }).value).toBe(-3.14); + }); + + it('corrects binary floating-point drift', () => { + // 0.69 * 10 in raw float math is 6.8999999999999995 (would floor to 6.8 without correction) + expect(usePrecision(0.69, 1).value).toBe(0.7); + expect(usePrecision(0.615, 2).value).toBe(0.62); + expect(usePrecision(1.255, 2).value).toBe(1.26); + }); + + it('handles negative digits (rounding to tens, hundreds, ...)', () => { + expect(usePrecision(1234, -2).value).toBe(1200); + expect(usePrecision(1280, -2, { math: 'ceil' }).value).toBe(1300); + }); + + it('leaves integers untouched', () => { + expect(usePrecision(42, 2).value).toBe(42); + expect(usePrecision(42, 0).value).toBe(42); + }); +}); diff --git a/vue/toolkit/src/composables/math/usePrecision/index.ts b/vue/toolkit/src/composables/math/usePrecision/index.ts new file mode 100644 index 0000000..001e235 --- /dev/null +++ b/vue/toolkit/src/composables/math/usePrecision/index.ts @@ -0,0 +1,78 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UsePrecisionMath + = 'floor' | 'ceil' | 'round'; + +export interface UsePrecisionOptions { + /** + * The `Math` method used to reduce the number to the requested precision. + * + * @default 'round' + */ + math?: UsePrecisionMath; +} + +export type UsePrecisionReturn + = ComputedRef; + +/** + * Multiply a value by a power of ten while compensating for binary + * floating-point drift (e.g. `0.1 * 100` -> `10` instead of `10.000000000000002`). + * + * @param {number} value The value to scale + * @param {number} power The multiplier (a power of ten) + * @returns {number} The drift-corrected product + */ +function accurateMultiply(value: number, power: number): number { + const valueStr = value.toString(); + const dotIndex = valueStr.indexOf('.'); + + if (dotIndex === -1) + return value * power; + + const decimalPlaces = valueStr.length - dotIndex - 1; + const multiplier = 10 ** decimalPlaces; + + return (value * multiplier * power) / multiplier; +} + +/** + * @name usePrecision + * @category Math + * @description Reactively set the decimal precision of a number. + * + * @param {MaybeRefOrGetter} value The source number + * @param {MaybeRefOrGetter} digits The number of decimal places to keep + * @param {MaybeRefOrGetter} [options] Precision options + * @param {UsePrecisionMath} [options.math='round'] The `Math` rounding method to apply + * @returns {UsePrecisionReturn} A computed ref holding the value at the requested precision + * + * @example + * const value = ref(3.14159); + * const result = usePrecision(value, 2); // 3.14 + * + * @example + * const value = ref(3.14159); + * const result = usePrecision(value, 2, { math: 'ceil' }); // 3.15 + * + * @example + * const value = ref(3.14159); + * const digits = ref(2); + * const result = usePrecision(value, digits); // reacts to value and digits + * + * @since 0.0.15 + */ +export function usePrecision( + value: MaybeRefOrGetter, + digits: MaybeRefOrGetter, + options?: MaybeRefOrGetter, +): UsePrecisionReturn { + return computed(() => { + const _value = toValue(value); + const power = 10 ** toValue(digits); + const math = toValue(options)?.math ?? 'round'; + + return Math[math](accurateMultiply(_value, power)) / power; + }); +} diff --git a/vue/toolkit/src/composables/math/useProjection/demo.vue b/vue/toolkit/src/composables/math/useProjection/demo.vue new file mode 100644 index 0000000..d1aa575 --- /dev/null +++ b/vue/toolkit/src/composables/math/useProjection/demo.vue @@ -0,0 +1,76 @@ + + + diff --git a/vue/toolkit/src/composables/math/useProjection/index.test.ts b/vue/toolkit/src/composables/math/useProjection/index.test.ts new file mode 100644 index 0000000..1992e09 --- /dev/null +++ b/vue/toolkit/src/composables/math/useProjection/index.test.ts @@ -0,0 +1,144 @@ +import { computed, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { createGenericProjection, createProjection, useProjection } from '.'; + +describe(useProjection, () => { + it('projects a static value linearly', () => { + const projected = useProjection(50, [0, 100], [0, 1]); + + expect(projected.value).toBe(0.5); + }); + + it('reacts to input changes', () => { + const input = ref(0); + const projected = useProjection(input, [0, 100], [0, 1]); + + expect(projected.value).toBe(0); + + input.value = 100; + expect(projected.value).toBe(1); + + input.value = 25; + expect(projected.value).toBe(0.25); + }); + + it('reacts to domain changes (getters)', () => { + const input = ref(5); + const from = ref<[number, number]>([0, 10]); + const to = ref<[number, number]>([0, 100]); + const projected = useProjection(input, () => from.value, () => to.value); + + expect(projected.value).toBe(50); + + to.value = [0, 200]; + expect(projected.value).toBe(100); + + from.value = [0, 5]; + expect(projected.value).toBe(200); + }); + + it('extrapolates past the bounds by default', () => { + const projected = useProjection(150, [0, 100], [0, 10]); + + expect(projected.value).toBe(15); + }); + + it('clamps to the from-domain when { clamp: true }', () => { + const above = useProjection(150, [0, 100], [0, 10], { clamp: true }); + const below = useProjection(-50, [0, 100], [0, 10], { clamp: true }); + + expect(above.value).toBe(10); + expect(below.value).toBe(0); + }); + + it('handles a reversed output domain', () => { + const projected = useProjection(0, [0, 100], [10, 0]); + + expect(projected.value).toBe(10); + }); + + it('maps a degenerate (zero-width) from-domain to the to-start instead of NaN', () => { + const projected = useProjection(7, [5, 5], [0, 100]); + + expect(projected.value).toBe(0); + expect(Number.isNaN(projected.value)).toBeFalsy(); + }); + + it('accepts a custom projector function', () => { + const projector = (input: number, from: readonly [number, number], to: readonly [number, number]): number => + Math.round((input - from[0]) / (from[1] - from[0]) * (to[1] - to[0]) + to[0]); + const projected = useProjection(0.4, [0, 1], [0, 3], projector); + + expect(projected.value).toBe(1); + }); + + it('works with readonly/computed inputs (SSR-safe, pure)', () => { + const source = computed(() => 25); + const projected = useProjection(source, [0, 100], [0, 4]); + + expect(projected.value).toBe(1); + }); +}); + +describe(createProjection, () => { + it('returns a reusable factory', () => { + const project = createProjection([0, 10], [0, 100]); + + expect(project(0).value).toBe(0); + expect(project(5).value).toBe(50); + expect(project(10).value).toBe(100); + }); + + it('keeps each produced ref reactive to its own input', () => { + const project = createProjection([0, 10], [0, 100]); + const a = ref(1); + const b = ref(2); + const pa = project(a); + const pb = project(b); + + a.value = 3; + expect(pa.value).toBe(30); + expect(pb.value).toBe(20); + }); + + it('supports the clamp option', () => { + const project = createProjection([0, 10], [0, 100], { clamp: true }); + + expect(project(20).value).toBe(100); + expect(project(-5).value).toBe(0); + }); +}); + +describe(createGenericProjection, () => { + it('projects across non-numeric domains via a custom projector', () => { + const project = createGenericProjection( + [0, 25], + ['a', 'z'], + (n, from, to) => { + const lo = to[0].charCodeAt(0); + const hi = to[1].charCodeAt(0); + const t = (n - from[0]) / (from[1] - from[0]); + + return String.fromCharCode(Math.round(lo + t * (hi - lo))); + }, + ); + + expect(project(0).value).toBe('a'); + expect(project(25).value).toBe('z'); + }); + + it('reacts to a reactive input', () => { + const input = ref(0); + const project = createGenericProjection( + [0, 10], + [0, 1], + (n, from, to) => (n - from[0]) / (from[1] - from[0]) * (to[1] - to[0]) + to[0], + ); + const projected = project(input); + + expect(projected.value).toBe(0); + + input.value = 10; + expect(projected.value).toBe(1); + }); +}); diff --git a/vue/toolkit/src/composables/math/useProjection/index.ts b/vue/toolkit/src/composables/math/useProjection/index.ts new file mode 100644 index 0000000..560bdfb --- /dev/null +++ b/vue/toolkit/src/composables/math/useProjection/index.ts @@ -0,0 +1,156 @@ +import { inverseLerp, lerp, remap } from '@robonen/stdlib'; +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * A pure mapping from a value in the `from` domain to the `to` domain. + * + * @typeParam F The input domain element type. + * @typeParam T The output domain element type. + */ +export type ProjectorFunction + = (input: F, from: readonly [F, F], to: readonly [T, T]) => T; + +/** + * A reusable projection: given a reactive input it yields a `ComputedRef` + * of the projected value. + * + * @typeParam F The input domain element type. + * @typeParam T The output domain element type. + */ +export type UseProjection + = (input: MaybeRefOrGetter) => ComputedRef; + +export interface CreateProjectionOptions { + /** + * Clamp the input to the `from` domain before projecting so the result never + * extrapolates past the `to` domain bounds. Reuses the stdlib `remap` (which + * also tolerates a reversed/descending input range). Defaults to `false`, + * matching VueUse's extrapolating behaviour. + * + * @default false + */ + clamp?: boolean; +} + +// Extrapolating linear projector (matches VueUse's defaultNumericProjector) but +// composed from stdlib lerp/inverseLerp and guarded against a zero-width input +// domain so a degenerate `[n, n]` source maps to `to[0]` instead of NaN. +function defaultNumericProjector(input: number, from: readonly [number, number], to: readonly [number, number]): number { + return lerp(to[0], to[1], inverseLerp(from[0], from[1], input)); +} + +// Clamping projector — delegates entirely to stdlib `remap`. +function clampedNumericProjector(input: number, from: readonly [number, number], to: readonly [number, number]): number { + return remap(input, from[0], from[1], to[0], to[1]); +} + +/** + * @name createGenericProjection + * @category Math + * @description Create a reusable projection between two arbitrary (non-numeric) + * domains using a custom projector. The returned factory turns a reactive input + * into a `ComputedRef` of the projected value, so the same projection can be + * applied to many inputs without re-resolving the domains each time. + * + * @typeParam F The input domain element type. + * @typeParam T The output domain element type. + * @param {MaybeRefOrGetter} fromDomain The source domain `[start, end]` + * @param {MaybeRefOrGetter} toDomain The target domain `[start, end]` + * @param {ProjectorFunction} projector The pure mapping function + * @returns {UseProjection} A factory that projects a reactive input into a `ComputedRef` + * + * @example + * const project = createGenericProjection( + * [0, 10], + * ['a', 'z'], + * (n, from, to) => to[0] + Math.round((n - from[0]) / (from[1] - from[0]) * (to[1].charCodeAt(0) - to[0].charCodeAt(0))), + * ); + * + * @since 0.0.15 + */ +/* @__NO_SIDE_EFFECTS__ */ +export function createGenericProjection( + fromDomain: MaybeRefOrGetter, + toDomain: MaybeRefOrGetter, + projector: ProjectorFunction, +): UseProjection { + return (input: MaybeRefOrGetter): ComputedRef => + computed(() => projector(toValue(input), toValue(fromDomain), toValue(toDomain))); +} + +/** + * @name createProjection + * @category Math + * @description Create a reusable numeric projection from one numeric domain to + * another. Without a custom projector it performs a linear (lerp-based) + * remap that extrapolates past the bounds; pass `{ clamp: true }` to clamp the + * input to the `from` domain via the stdlib `remap`. The returned factory can + * be reused for many inputs. + * + * @param {MaybeRefOrGetter} fromDomain The source domain `[start, end]` + * @param {MaybeRefOrGetter} toDomain The target domain `[start, end]` + * @param {ProjectorFunction | CreateProjectionOptions} [projector] A custom projector, or options for the default projector + * @returns {UseProjection} A factory that projects a reactive number into a `ComputedRef` + * + * @example + * const project = createProjection([0, 100], [0, 1]); + * const half = project(50); // 0.5 + * + * @example + * const project = createProjection([0, 10], [0, 100], { clamp: true }); + * const out = project(20); // 100 (clamped) + * + * @since 0.0.15 + */ +/* @__NO_SIDE_EFFECTS__ */ +export function createProjection( + fromDomain: MaybeRefOrGetter, + toDomain: MaybeRefOrGetter, + projector?: ProjectorFunction | CreateProjectionOptions, +): UseProjection { + const resolved: ProjectorFunction + = typeof projector === 'function' + ? projector + : projector?.clamp + ? clampedNumericProjector + : defaultNumericProjector; + + return createGenericProjection(fromDomain, toDomain, resolved); +} + +/** + * @name useProjection + * @category Math + * @description Reactive numeric projection from one numeric domain to another. + * A thin one-shot wrapper over {@link createProjection}: it projects a single + * reactive `input` and returns a `ComputedRef` of the result. The default + * (lerp-based) projector extrapolates past the domain bounds; pass + * `{ clamp: true }` to clamp the input to the `from` domain. SSR-safe — it + * performs only pure arithmetic and touches no browser globals. + * + * @param {MaybeRefOrGetter} input The reactive value to project + * @param {MaybeRefOrGetter} fromDomain The source domain `[start, end]` + * @param {MaybeRefOrGetter} toDomain The target domain `[start, end]` + * @param {ProjectorFunction | CreateProjectionOptions} [projector] A custom projector, or options for the default projector + * @returns {ComputedRef} A computed ref of the projected value + * + * @example + * const input = ref(50); + * const projected = useProjection(input, [0, 100], [0, 1]); // 0.5 + * + * @example + * const input = ref(150); + * const projected = useProjection(input, [0, 100], [0, 10], { clamp: true }); // 10 + * + * @since 0.0.15 + */ +/* @__NO_SIDE_EFFECTS__ */ +export function useProjection( + input: MaybeRefOrGetter, + fromDomain: MaybeRefOrGetter, + toDomain: MaybeRefOrGetter, + projector?: ProjectorFunction | CreateProjectionOptions, +): ComputedRef { + return createProjection(fromDomain, toDomain, projector)(input); +} diff --git a/vue/toolkit/src/composables/math/useRound/demo.vue b/vue/toolkit/src/composables/math/useRound/demo.vue new file mode 100644 index 0000000..365ffc6 --- /dev/null +++ b/vue/toolkit/src/composables/math/useRound/demo.vue @@ -0,0 +1,66 @@ + + + diff --git a/vue/toolkit/src/composables/math/useRound/index.test.ts b/vue/toolkit/src/composables/math/useRound/index.test.ts new file mode 100644 index 0000000..115a268 --- /dev/null +++ b/vue/toolkit/src/composables/math/useRound/index.test.ts @@ -0,0 +1,106 @@ +import { computed, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useRound } from '.'; + +describe(useRound, () => { + it('rounds a non-reactive value', () => { + expect(useRound(0.6).value).toBe(1); + expect(useRound(0.4).value).toBe(0); + expect(useRound(-0.6).value).toBe(-1); + }); + + it('rounds a ref value', () => { + const value = ref(1.49); + const rounded = useRound(value); + + expect(rounded.value).toBe(1); + }); + + it('rounds a getter value', () => { + const value = ref(2.5); + const rounded = useRound(() => value.value); + + expect(rounded.value).toBe(3); + }); + + it('updates reactively when the source value changes', () => { + const value = ref(1.2); + const rounded = useRound(value); + + expect(rounded.value).toBe(1); + + value.value = 1.8; + + expect(rounded.value).toBe(2); + }); + + it('rounds a readonly computed source', () => { + const source = computed(() => 4.7); + const rounded = useRound(source); + + expect(rounded.value).toBe(5); + }); + + it('matches Math.round semantics for halves', () => { + expect(useRound(0.5).value).toBe(1); + expect(useRound(2.5).value).toBe(3); + // Math.round(-0.5) === -0, which our composable preserves exactly. + expect(useRound(-0.5).value).toBe(Math.round(-0.5)); + expect(useRound(-1.5).value).toBe(-1); + }); + + it('rounds to a fixed number of decimal places', () => { + expect(useRound(1.2345, { digits: 2 }).value).toBe(1.23); + expect(useRound(1.2355, { digits: 2 }).value).toBe(1.24); + expect(useRound(1.005, { digits: 2 }).value).toBe(1.01); + }); + + it('rounds negative numbers with decimal precision', () => { + expect(useRound(-1.2345, { digits: 2 }).value).toBe(-1.23); + expect(useRound(-1.2355, { digits: 2 }).value).toBe(-1.24); + }); + + it('rounds to the left of the decimal point with negative digits', () => { + expect(useRound(1234, { digits: -1 }).value).toBe(1230); + expect(useRound(1250, { digits: -2 }).value).toBe(1300); + }); + + it('treats digits 0 as plain Math.round', () => { + expect(useRound(1.6, { digits: 0 }).value).toBe(2); + }); + + it('reacts to a reactive digits option', () => { + const value = ref(1.23456); + const digits = ref(2); + const rounded = useRound(value, { digits }); + + expect(rounded.value).toBe(1.23); + + digits.value = 4; + + expect(rounded.value).toBe(1.2346); + }); + + it('accepts a getter for digits', () => { + const digits = ref(1); + const rounded = useRound(3.14159, { digits: () => digits.value }); + + expect(rounded.value).toBe(3.1); + }); + + it('passes through non-finite values', () => { + expect(useRound(Number.NaN).value).toBeNaN(); + expect(useRound(Number.POSITIVE_INFINITY).value).toBe(Number.POSITIVE_INFINITY); + expect(useRound(Number.NEGATIVE_INFINITY, { digits: 2 }).value).toBe(Number.NEGATIVE_INFINITY); + expect(useRound(Number.NaN, { digits: 2 }).value).toBeNaN(); + }); + + it('works without any DOM globals (SSR-safe, pure computed)', () => { + // The composable never touches window/document/navigator, so it must work + // identically regardless of environment. + const value = ref(9.99); + const rounded = useRound(value, { digits: 1 }); + + expect(rounded.value).toBe(10); + }); +}); diff --git a/vue/toolkit/src/composables/math/useRound/index.ts b/vue/toolkit/src/composables/math/useRound/index.ts new file mode 100644 index 0000000..f1b2327 --- /dev/null +++ b/vue/toolkit/src/composables/math/useRound/index.ts @@ -0,0 +1,67 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export interface UseRoundOptions { + /** + * Number of decimal places to round to. + * + * `0` (default) reproduces `Math.round` exactly. Positive values round to + * fractional digits (e.g. `2` -> `1.23`), negative values round to the left + * of the decimal point (e.g. `-1` -> nearest ten). + * + * @default 0 + */ + digits?: MaybeRefOrGetter; +} + +/** + * @name useRound + * @category Math + * @description Reactive `Math.round` with optional decimal-place precision + * + * @param {MaybeRefOrGetter} value The value to round + * @param {UseRoundOptions} [options] Rounding options + * @returns {ComputedRef} A computed ref of the rounded value + * + * @example + * const value = ref(0.6); + * const rounded = useRound(value); // 1 + * + * @example + * const value = ref(1.2345); + * const rounded = useRound(value, { digits: 2 }); // 1.23 + * + * @example + * const value = ref(0.5); + * const rounded = useRound(() => value.value); // 1 + * + * @since 0.0.15 + */ +export function useRound(value: MaybeRefOrGetter, options: UseRoundOptions = {}): ComputedRef { + const { digits = 0 } = options; + + return computed(() => { + const v = toValue(value); + const d = toValue(digits); + + // Fast path: identical to Math.round, avoids any precision math. + if (!d) + return Math.round(v); + + // Non-finite inputs (NaN/Infinity) round to themselves; bail early so the + // factor multiplication below does not turn Infinity into NaN. + if (!Number.isFinite(v)) + return v; + + // Scale into integer space, round, then scale back. Using `Number(... )` + // through exponential notation sidesteps the classic float artifacts of + // `Math.round(v * factor) / factor` (e.g. 1.005 -> 1.00). + const sign = v < 0 ? '-' : ''; + const abs = Math.abs(v); + + const shifted = Number(`${abs}e${d}`); + const rounded = Math.round(shifted); + + return Number(`${sign}${rounded}e${-d}`); + }); +} diff --git a/vue/toolkit/src/composables/math/useSum/demo.vue b/vue/toolkit/src/composables/math/useSum/demo.vue new file mode 100644 index 0000000..4921378 --- /dev/null +++ b/vue/toolkit/src/composables/math/useSum/demo.vue @@ -0,0 +1,83 @@ + + + diff --git a/vue/toolkit/src/composables/math/useSum/index.test.ts b/vue/toolkit/src/composables/math/useSum/index.test.ts new file mode 100644 index 0000000..136ec3c --- /dev/null +++ b/vue/toolkit/src/composables/math/useSum/index.test.ts @@ -0,0 +1,116 @@ +import { computed, readonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useSum } from '.'; + +describe(useSum, () => { + it('returns the sum of raw variadic values', () => { + const total = useSum(1, 3, 2); + + expect(total.value).toBe(6); + }); + + it('returns the sum of ref variadic values', () => { + const a = ref(1); + const b = ref(3); + const c = ref(2); + const total = useSum(a, b, c); + + expect(total.value).toBe(6); + }); + + it('supports getter arguments', () => { + const total = useSum(() => 4, () => 9, () => 1); + + expect(total.value).toBe(14); + }); + + it('mixes refs, getters and raw values', () => { + const a = ref(5); + const total = useSum(a, () => 8, 2); + + expect(total.value).toBe(15); + }); + + it('reacts to ref changes', () => { + const a = ref(1); + const b = ref(3); + const total = useSum(a, b); + + expect(total.value).toBe(4); + + a.value = 10; + + expect(total.value).toBe(13); + }); + + it('returns the sum of a reactive array', () => { + const list = ref([1, 5, 2]); + const total = useSum(list); + + expect(total.value).toBe(8); + + list.value = [7, 3, 4]; + + expect(total.value).toBe(14); + }); + + it('returns the sum of a getter array', () => { + const total = useSum(() => [1, 9, 4]); + + expect(total.value).toBe(14); + }); + + it('unwraps refs and getters nested inside the array', () => { + const a = ref(1); + const total = useSum(ref([a, () => 5, 2])); + + expect(total.value).toBe(8); + + a.value = 99; + + expect(total.value).toBe(106); + }); + + it('reacts when a mutated plain array ref is reassigned', () => { + const list = ref([1, 2, 3]); + const total = useSum(list); + + expect(total.value).toBe(6); + + list.value = [...list.value, 100]; + + expect(total.value).toBe(106); + }); + + it('handles readonly and computed sources', () => { + const computedValue = computed(() => 12); + const readonlyValue = readonly(ref(7)); + const total = useSum(computedValue, readonlyValue, 3); + + expect(total.value).toBe(22); + }); + + it('handles negative values', () => { + const total = useSum(-5, -1, -10); + + expect(total.value).toBe(-16); + }); + + it('returns 0 for an empty array (SSR-safe, no globals touched)', () => { + const total = useSum(ref([])); + + expect(total.value).toBe(0); + }); + + it('returns 0 when called with no arguments', () => { + const total = useSum(); + + expect(total.value).toBe(0); + }); + + it('returns a single value when only one argument is given', () => { + const total = useSum(42); + + expect(total.value).toBe(42); + }); +}); diff --git a/vue/toolkit/src/composables/math/useSum/index.ts b/vue/toolkit/src/composables/math/useSum/index.ts new file mode 100644 index 0000000..0737b9f --- /dev/null +++ b/vue/toolkit/src/composables/math/useSum/index.ts @@ -0,0 +1,54 @@ +import { isArray, sum } from '@robonen/stdlib'; +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import type { MaybeComputedRefArgs } from '@/types'; + +/** + * @name useSum + * @category Math + * @description Reactively compute the sum of the provided numbers. Accepts + * either a variadic list of numbers (each a ref, getter, or raw value) or a + * single reactive array whose items may themselves be refs/getters. + * + * @param {...MaybeRefOrGetter} args The values to add, or a single reactive array of values + * @returns {ComputedRef} A computed ref of the total (`0` when empty) + * + * @example + * const a = ref(1); + * const b = ref(3); + * const total = useSum(a, b, 2); // 6 + * + * @example + * const list = ref([1, 5, 2]); + * const total = useSum(list); // 8 + * + * @example + * const list = ref([ref(1), () => 5, 2]); + * const total = useSum(list); // 8 + * + * @since 0.0.15 + */ +export function useSum(array: MaybeRefOrGetter>>): ComputedRef; +export function useSum(...args: Array>): ComputedRef; +export function useSum(...args: MaybeComputedRefArgs): ComputedRef { + return computed(() => { + // Collect the resolved values into a single flat array, unwrapping refs and + // getters at both the top level and inside a reactive array argument, then + // delegate the reduction to stdlib `sum` (one pass, no per-arg closures). + const values: number[] = []; + + for (const arg of args) { + const value = toValue(arg); + + if (isArray(value)) { + for (const inner of value) + values.push(toValue(inner)); + } + else { + values.push(value); + } + } + + return sum(values); + }); +} diff --git a/vue/toolkit/src/composables/math/useTrunc/demo.vue b/vue/toolkit/src/composables/math/useTrunc/demo.vue new file mode 100644 index 0000000..3585528 --- /dev/null +++ b/vue/toolkit/src/composables/math/useTrunc/demo.vue @@ -0,0 +1,57 @@ + + + diff --git a/vue/toolkit/src/composables/math/useTrunc/index.test.ts b/vue/toolkit/src/composables/math/useTrunc/index.test.ts new file mode 100644 index 0000000..4be6f07 --- /dev/null +++ b/vue/toolkit/src/composables/math/useTrunc/index.test.ts @@ -0,0 +1,64 @@ +import { computed, readonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; +import { useTrunc } from '.'; + +describe(useTrunc, () => { + it('truncate a non-reactive value', () => { + const truncated = useTrunc(2.9); + + expect(truncated.value).toBe(2); + }); + + it('truncate negative values toward zero', () => { + expect(useTrunc(-3.7).value).toBe(-3); + expect(useTrunc(-0.4).value).toBe(-0); + }); + + it('leave integers unchanged', () => { + expect(useTrunc(5).value).toBe(5); + expect(useTrunc(-5).value).toBe(-5); + expect(useTrunc(0).value).toBe(0); + }); + + it('truncate a reactive ref', () => { + const value = ref(2.9); + const truncated = useTrunc(value); + + expect(truncated.value).toBe(2); + }); + + it('truncate using a getter', () => { + const truncated = useTrunc(() => 4.999); + + expect(truncated.value).toBe(4); + }); + + it('truncate readonly values', () => { + const computedValue = computed(() => 7.5); + const readonlyValue = readonly(ref(7.5)); + + expect(useTrunc(computedValue).value).toBe(7); + expect(useTrunc(readonlyValue).value).toBe(7); + }); + + it('update the truncated value when the original value changes', () => { + const value = ref(2.9); + const truncated = useTrunc(value); + + expect(truncated.value).toBe(2); + + value.value = 9.99; + + expect(truncated.value).toBe(9); + + value.value = -1.5; + + expect(truncated.value).toBe(-1); + }); + + it('propagate non-finite inputs', () => { + expect(useTrunc(Number.POSITIVE_INFINITY).value).toBe(Number.POSITIVE_INFINITY); + expect(useTrunc(Number.NEGATIVE_INFINITY).value).toBe(Number.NEGATIVE_INFINITY); + expect(useTrunc(Number.NaN).value).toBeNaN(); + }); +}); diff --git a/vue/toolkit/src/composables/math/useTrunc/index.ts b/vue/toolkit/src/composables/math/useTrunc/index.ts new file mode 100644 index 0000000..23c1e03 --- /dev/null +++ b/vue/toolkit/src/composables/math/useTrunc/index.ts @@ -0,0 +1,25 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * @name useTrunc + * @category Math + * @description Reactive `Math.trunc`. Returns the integer part of a number by removing any fractional digits. + * + * @param {MaybeRefOrGetter} value The value to truncate + * @returns {ComputedRef} A computed ref holding the truncated value + * + * @example + * const value = ref(2.9); + * const truncated = useTrunc(value); + * // truncated.value === 2 + * + * @example + * const truncated = useTrunc(() => -3.7); + * // truncated.value === -3 + * + * @since 0.0.15 + */ +export function useTrunc(value: MaybeRefOrGetter): ComputedRef { + return computed(() => Math.trunc(toValue(value))); +} diff --git a/vue/toolkit/src/composables/media/index.ts b/vue/toolkit/src/composables/media/index.ts new file mode 100644 index 0000000..5f02dde --- /dev/null +++ b/vue/toolkit/src/composables/media/index.ts @@ -0,0 +1,10 @@ +export * from './useBluetooth'; +export * from './useDisplayMedia'; +export * from './useMediaControls'; +export * from './useMemory'; +export * from './usePerformanceObserver'; +export * from './useSpeechRecognition'; +export * from './useSpeechSynthesis'; +export * from './useUserMedia'; +export * from './useWebWorker'; +export * from './useWebWorkerFn'; diff --git a/vue/toolkit/src/composables/media/useBluetooth/demo.vue b/vue/toolkit/src/composables/media/useBluetooth/demo.vue new file mode 100644 index 0000000..400a889 --- /dev/null +++ b/vue/toolkit/src/composables/media/useBluetooth/demo.vue @@ -0,0 +1,92 @@ + + + diff --git a/vue/toolkit/src/composables/media/useBluetooth/index.test.ts b/vue/toolkit/src/composables/media/useBluetooth/index.test.ts new file mode 100644 index 0000000..61654e2 --- /dev/null +++ b/vue/toolkit/src/composables/media/useBluetooth/index.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import type { UseBluetoothReturn } from '.'; +import { useBluetooth } from '.'; + +interface StubGatt { + connect: ReturnType; + disconnect: ReturnType; +} + +function makeServer(connected = true) { + return { connected } as unknown as BluetoothRemoteGATTServer; +} + +function makeDevice(gattConnected = true) { + const listeners = new Map>(); + const server = makeServer(gattConnected); + const gatt: StubGatt = { + connect: vi.fn(async () => server), + disconnect: vi.fn(), + }; + const device = { + name: 'stub-device', + gatt, + addEventListener: vi.fn((type: string, fn: EventListener) => { + if (!listeners.has(type)) + listeners.set(type, new Set()); + listeners.get(type)!.add(fn); + }), + removeEventListener: vi.fn((type: string, fn: EventListener) => { + listeners.get(type)?.delete(fn); + }), + dispatch: (type: string) => { + listeners.get(type)?.forEach(fn => fn(new Event(type))); + }, + }; + return { device, gatt, server }; +} + +function stubBluetooth(device?: ReturnType['device']) { + const requestDevice = vi.fn(async () => device); + const navigator = { + bluetooth: { requestDevice }, + } as unknown as Navigator; + return { navigator, requestDevice }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe(useBluetooth, () => { + it('reports support when the Bluetooth API exists', () => { + const { navigator } = stubBluetooth(); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator }); + }); + expect(bt!.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('is not supported without the Bluetooth API', () => { + const navigator = {} as unknown as Navigator; + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator }); + }); + expect(bt!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('is not supported when navigator is undefined (SSR)', () => { + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator: undefined }); + }); + expect(bt!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('does nothing when requestDevice is called unsupported', async () => { + const navigator = {} as unknown as Navigator; + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator }); + }); + await bt!.requestDevice(); + expect(bt!.device.value).toBeUndefined(); + expect(bt!.error.value).toBeNull(); + scope.stop(); + }); + + it('requests a device and connects to its GATT server', async () => { + const { device, gatt, server } = makeDevice(true); + const { navigator, requestDevice } = stubBluetooth(device); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true }); + }); + + await bt!.requestDevice(); + expect(requestDevice).toHaveBeenCalledWith({ + acceptAllDevices: true, + filters: undefined, + optionalServices: undefined, + }); + expect(bt!.device.value).toBe(device); + + // The device watcher triggers connect() asynchronously + await nextTick(); + await Promise.resolve(); + expect(gatt.connect).toHaveBeenCalled(); + expect(bt!.server.value).toBe(server); + expect(bt!.isConnected.value).toBeTruthy(); + scope.stop(); + }); + + it('forces acceptAllDevices off when filters are provided', async () => { + const { device } = makeDevice(); + const { navigator, requestDevice } = stubBluetooth(device); + const filters = [{ services: ['heart_rate'] }] as BluetoothLEScanFilter[]; + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true, filters }); + }); + + await bt!.requestDevice(); + expect(requestDevice).toHaveBeenCalledWith({ + acceptAllDevices: false, + filters, + optionalServices: undefined, + }); + scope.stop(); + }); + + it('captures errors from requestDevice and calls onError', async () => { + const requestDevice = vi.fn(async () => { + throw new Error('user cancelled'); + }); + const navigator = { bluetooth: { requestDevice } } as unknown as Navigator; + const onError = vi.fn(); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true, onError }); + }); + + await bt!.requestDevice(); + expect(bt!.error.value).toBeInstanceOf(Error); + expect((bt!.error.value as Error).message).toBe('user cancelled'); + expect(onError).toHaveBeenCalledWith(bt!.error.value); + expect(bt!.device.value).toBeUndefined(); + scope.stop(); + }); + + it('captures errors from the GATT connection', async () => { + const { device, gatt } = makeDevice(); + gatt.connect.mockRejectedValueOnce(new Error('gatt failed')); + const { navigator } = stubBluetooth(device); + const onError = vi.fn(); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true, onError }); + }); + + await bt!.requestDevice(); + await nextTick(); + await Promise.resolve(); + await Promise.resolve(); + expect(bt!.error.value).toBeInstanceOf(Error); + expect(onError).toHaveBeenCalled(); + expect(bt!.isConnected.value).toBeFalsy(); + expect(bt!.server.value).toBeUndefined(); + scope.stop(); + }); + + it('resets connection state on gattserverdisconnected', async () => { + const { device } = makeDevice(true); + const { navigator } = stubBluetooth(device); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true }); + }); + + await bt!.requestDevice(); + await nextTick(); + await Promise.resolve(); + expect(bt!.isConnected.value).toBeTruthy(); + + device.dispatch('gattserverdisconnected'); + expect(bt!.isConnected.value).toBeFalsy(); + expect(bt!.server.value).toBeUndefined(); + scope.stop(); + }); + + it('disconnect() disconnects the gatt server and resets state', async () => { + const { device, gatt } = makeDevice(true); + const { navigator } = stubBluetooth(device); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true }); + }); + + await bt!.requestDevice(); + await nextTick(); + await Promise.resolve(); + expect(bt!.isConnected.value).toBeTruthy(); + + bt!.disconnect(); + expect(gatt.disconnect).toHaveBeenCalled(); + expect(bt!.isConnected.value).toBeFalsy(); + expect(bt!.server.value).toBeUndefined(); + scope.stop(); + }); + + it('connect() reconnects to the current device', async () => { + const { device, gatt } = makeDevice(true); + const { navigator } = stubBluetooth(device); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true }); + }); + + await bt!.requestDevice(); + await nextTick(); + await Promise.resolve(); + gatt.connect.mockClear(); + + await bt!.connect(); + expect(gatt.connect).toHaveBeenCalledTimes(1); + expect(bt!.isConnected.value).toBeTruthy(); + scope.stop(); + }); + + it('disconnects the gatt server when the scope is disposed', async () => { + const { device, gatt } = makeDevice(true); + const { navigator } = stubBluetooth(device); + const scope = effectScope(); + let bt: UseBluetoothReturn; + scope.run(() => { + bt = useBluetooth({ navigator, acceptAllDevices: true }); + }); + + await bt!.requestDevice(); + await nextTick(); + await Promise.resolve(); + gatt.disconnect.mockClear(); + + scope.stop(); + expect(gatt.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/media/useBluetooth/index.ts b/vue/toolkit/src/composables/media/useBluetooth/index.ts new file mode 100644 index 0000000..0fa6c31 --- /dev/null +++ b/vue/toolkit/src/composables/media/useBluetooth/index.ts @@ -0,0 +1,218 @@ +import { shallowReadonly, shallowRef, watch } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultNavigator } from '@/types'; +import type { ConfigurableNavigator } from '@/types'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseBluetoothRequestDeviceOptions { + /** + * An array of `BluetoothLEScanFilter`. Each filter consists of an array of + * `BluetoothServiceUUID`s, a `name` parameter, and a `namePrefix` parameter. + */ + filters?: BluetoothLEScanFilter[]; + + /** + * An array of `BluetoothServiceUUID`s that the device may expose but that are + * not part of `filters`. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/BluetoothRemoteGATTService/uuid + */ + optionalServices?: BluetoothServiceUUID[]; +} + +export interface UseBluetoothOptions extends UseBluetoothRequestDeviceOptions, ConfigurableNavigator { + /** + * Accept all Bluetooth devices in the chooser. + * + * !! Showing every nearby device wastes energy (no filters) and is rarely what + * you want — prefer `filters`. Ignored when `filters` is non-empty. + * + * @default false + */ + acceptAllDevices?: boolean; + + /** + * Called when `requestDevice` or a GATT connection rejects, instead of + * throwing. The same value is also stored in the returned `error` ref. + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export interface UseBluetoothReturn { + /** + * Whether the Web Bluetooth API is available + */ + isSupported: Readonly>; + + /** + * Whether a GATT server connection is currently established + */ + isConnected: Readonly>; + + /** + * The selected Bluetooth device, or `undefined` before one is chosen + */ + device: ShallowRef; + + /** + * Prompt the user to pick a device, then connect to its GATT server + */ + requestDevice: () => Promise; + + /** + * Re-establish the GATT connection to the current `device` + */ + connect: () => Promise; + + /** + * Disconnect from the current device's GATT server + */ + disconnect: () => void; + + /** + * The connected GATT server, or `undefined` while disconnected + */ + server: ShallowRef; + + /** + * The last error thrown by `requestDevice` or a connection attempt + */ + error: ShallowRef; +} + +/** + * @name useBluetooth + * @category Media + * @description Reactive Web Bluetooth API. Prompts for a device and tracks its GATT server connection. + * + * @param {UseBluetoothOptions} [options={}] Options + * @param {boolean} [options.acceptAllDevices=false] Show all devices in the chooser (ignored when `filters` is set) + * @param {BluetoothLEScanFilter[]} [options.filters] Device scan filters + * @param {BluetoothServiceUUID[]} [options.optionalServices] Optional GATT services to request access to + * @param {Function} [options.onError=noop] Error callback invoked instead of throwing + * @param {Navigator} [options.navigator=defaultNavigator] Custom `navigator` instance + * @returns {UseBluetoothReturn} `isSupported`, `isConnected`, `device`, `requestDevice`, `connect`, `disconnect`, `server`, and `error` + * + * @example + * const { isSupported, isConnected, device, requestDevice, server, error } = useBluetooth({ + * acceptAllDevices: true, + * }); + * requestDevice(); + * + * @example + * // Filter by advertised service and request access to the battery service + * const { device, requestDevice, server } = useBluetooth({ + * filters: [{ services: ['heart_rate'] }], + * optionalServices: ['battery_service'], + * }); + * + * @since 0.0.15 + */ +export function useBluetooth(options: UseBluetoothOptions = {}): UseBluetoothReturn { + const { + filters, + optionalServices, + navigator = defaultNavigator, + onError = noop, + } = options; + + // `acceptAllDevices` is forced off whenever filters narrow the chooser, so it lives in a local. + const hasFilters = !!filters && filters.length > 0; + const acceptAllDevices = hasFilters ? false : options.acceptAllDevices ?? false; + + const isSupported = useSupported(() => navigator && 'bluetooth' in navigator); + + const device = shallowRef(); + const server = shallowRef(); + const isConnected = shallowRef(false); + const error = shallowRef(null); + + function reset(): void { + isConnected.value = false; + server.value = undefined; + } + + function fail(err: unknown): void { + error.value = err; + onError(err); + } + + // A single reactive listener tied to `device` — re-binds itself whenever the device changes + // instead of stacking a new listener on every connect (as VueUse does). + useEventListener(device, 'gattserverdisconnected', reset, { passive: true }); + + async function connect(): Promise { + error.value = null; + + const gatt = device.value?.gatt; + + if (!gatt) + return; + + try { + server.value = await gatt.connect(); + isConnected.value = server.value.connected; + } + catch (err) { + reset(); + fail(err); + } + } + + function disconnect(): void { + device.value?.gatt?.disconnect(); + reset(); + } + + // Auto-(re)connect when a new device is selected. + watch(device, () => { + reset(); + connect(); + }); + + async function requestDevice(): Promise { + if (!isSupported.value) + return; + + error.value = null; + + try { + // `isSupported` guarantees `navigator.bluetooth` exists before we get here. + device.value = await navigator!.bluetooth!.requestDevice({ + acceptAllDevices, + filters, + optionalServices, + }); + } + catch (err) { + fail(err); + } + } + + tryOnMounted(() => { + if (device.value?.gatt) + connect(); + }); + + tryOnScopeDispose(() => { + if (device.value?.gatt) + device.value.gatt.disconnect(); + }); + + return { + isSupported, + isConnected: shallowReadonly(isConnected), + device, + requestDevice, + connect, + disconnect, + server, + error, + }; +} diff --git a/vue/toolkit/src/composables/media/useDisplayMedia/demo.vue b/vue/toolkit/src/composables/media/useDisplayMedia/demo.vue new file mode 100644 index 0000000..775b594 --- /dev/null +++ b/vue/toolkit/src/composables/media/useDisplayMedia/demo.vue @@ -0,0 +1,86 @@ + + + diff --git a/vue/toolkit/src/composables/media/useDisplayMedia/index.test.ts b/vue/toolkit/src/composables/media/useDisplayMedia/index.test.ts new file mode 100644 index 0000000..291e450 --- /dev/null +++ b/vue/toolkit/src/composables/media/useDisplayMedia/index.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import type { UseDisplayMediaReturn } from '.'; +import { useDisplayMedia } from '.'; + +function track() { + let endedHandler: ((ev: Event) => any) | undefined; + return { + stop: vi.fn(), + kind: 'video', + addEventListener: vi.fn((type: string, handler: any) => { + if (type === 'ended') endedHandler = handler; + }), + removeEventListener: vi.fn(() => { endedHandler = undefined; }), + emitEnded: () => endedHandler?.(new Event('ended')), + }; +} + +function stubDisplayMedia() { + const videoTrack = track(); + const media = { + getTracks: () => [videoTrack], + } as unknown as MediaStream; + + const getDisplayMedia = vi.fn(async () => media); + const mediaDevices = { getDisplayMedia } as unknown as MediaDevices; + const navigator = { mediaDevices } as unknown as Navigator; + + return { navigator, getDisplayMedia, media, videoTrack }; +} + +function host(fn: () => UseDisplayMediaReturn) { + const scope = effectScope(); + let result: UseDisplayMediaReturn; + scope.run(() => { + result = fn(); + }); + return { result: result!, scope }; +} + +describe(useDisplayMedia, () => { + it('reports unsupported when getDisplayMedia is missing', () => { + const { result, scope } = host(() => useDisplayMedia({ navigator: {} as Navigator })); + + expect(result.isSupported.value).toBeFalsy(); + expect(result.stream.value).toBeUndefined(); + scope.stop(); + }); + + it('handles the SSR path (no navigator) gracefully', async () => { + const { result, scope } = host(() => useDisplayMedia({ navigator: undefined })); + + expect(result.isSupported.value).toBeFalsy(); + await expect(result.start()).resolves.toBeUndefined(); + expect(result.enabled.value).toBeFalsy(); + scope.stop(); + }); + + it('reports supported and starts a stream', async () => { + const { navigator, getDisplayMedia, media } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator })); + + expect(result.isSupported.value).toBeTruthy(); + + const returned = await result.start(); + expect(returned).toBe(media); + expect(result.stream.value).toBe(media); + expect(result.enabled.value).toBeTruthy(); + expect(getDisplayMedia).toHaveBeenCalledWith({ audio: false, video: true }); + scope.stop(); + }); + + it('passes custom audio/video constraints', async () => { + const { navigator, getDisplayMedia } = stubDisplayMedia(); + const { result, scope } = host(() => + useDisplayMedia({ navigator, audio: true, video: { frameRate: 30 } })); + + await result.start(); + expect(getDisplayMedia).toHaveBeenCalledWith({ audio: true, video: { frameRate: 30 } }); + scope.stop(); + }); + + it('stop() releases tracks and clears the stream', async () => { + const { navigator, videoTrack } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator })); + + await result.start(); + result.stop(); + + expect(videoTrack.stop).toHaveBeenCalled(); + expect(result.stream.value).toBeUndefined(); + expect(result.enabled.value).toBeFalsy(); + scope.stop(); + }); + + it('registers the ended listener passively and stops on track end', async () => { + const { navigator, videoTrack } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator })); + + await result.start(); + expect(videoTrack.addEventListener).toHaveBeenCalledWith( + 'ended', + expect.any(Function), + { passive: true }, + ); + + videoTrack.emitEnded(); + expect(result.stream.value).toBeUndefined(); + expect(result.enabled.value).toBeFalsy(); + scope.stop(); + }); + + it('toggling enabled starts and stops the stream', async () => { + const { navigator, getDisplayMedia, videoTrack } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator })); + + result.enabled.value = true; + await nextTick(); + await vi.waitFor(() => expect(result.stream.value).toBeTruthy()); + expect(getDisplayMedia).toHaveBeenCalledTimes(1); + + result.enabled.value = false; + await nextTick(); + expect(videoTrack.stop).toHaveBeenCalled(); + expect(result.stream.value).toBeUndefined(); + scope.stop(); + }); + + it('starts immediately when enabled is true on init', async () => { + const { navigator, getDisplayMedia } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator, enabled: true })); + + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalled()); + expect(result.enabled.value).toBeTruthy(); + scope.stop(); + }); + + it('does not re-request when a stream is already running', async () => { + const { navigator, getDisplayMedia } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator })); + + await result.start(); + await result.start(); + expect(getDisplayMedia).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it('dedupes concurrent start() calls', async () => { + const { navigator, getDisplayMedia } = stubDisplayMedia(); + const { result, scope } = host(() => useDisplayMedia({ navigator })); + + await Promise.all([result.start(), result.start()]); + expect(getDisplayMedia).toHaveBeenCalledTimes(1); + scope.stop(); + }); + + it('reports getDisplayMedia rejection via onError and stays disabled', async () => { + const onError = vi.fn(); + const { navigator, getDisplayMedia } = stubDisplayMedia(); + (getDisplayMedia as any).mockRejectedValueOnce(new Error('cancelled')); + const { result, scope } = host(() => useDisplayMedia({ navigator, onError })); + + const returned = await result.start(); + expect(returned).toBeUndefined(); + expect(onError).toHaveBeenCalled(); + expect(result.stream.value).toBeUndefined(); + expect(result.enabled.value).toBeFalsy(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/media/useDisplayMedia/index.ts b/vue/toolkit/src/composables/media/useDisplayMedia/index.ts new file mode 100644 index 0000000..02444cc --- /dev/null +++ b/vue/toolkit/src/composables/media/useDisplayMedia/index.ts @@ -0,0 +1,172 @@ +import { shallowRef, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref, ShallowRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultNavigator } from '@/types'; +import type { ConfigurableNavigator } from '@/types'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { useEventListener } from '@/composables/browser/useEventListener'; + +export interface UseDisplayMediaOptions extends ConfigurableNavigator { + /** + * Whether the stream should be active. Toggling this reactively starts or + * stops the screen share. + * + * @default false + */ + enabled?: MaybeRefOrGetter; + + /** + * Video media constraints for the captured display surface. + * + * @default true + */ + video?: boolean | MediaTrackConstraints; + + /** + * Audio media constraints for the captured display surface. + * + * @default false + */ + audio?: boolean | MediaTrackConstraints; + + /** + * Called when `getDisplayMedia` rejects (e.g. the user cancels the picker). + * Defaults to a no-op — we never log to the console. + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export interface UseDisplayMediaReturn { + /** + * Whether `navigator.mediaDevices.getDisplayMedia` is available. + */ + isSupported: Readonly>; + + /** + * The active `MediaStream`, or `undefined` when not sharing. + */ + stream: ShallowRef; + + /** + * Request the screen-share picker and start the stream. Resolves to the + * stream (or `undefined` when unsupported / cancelled). Concurrent calls are + * deduped and an already-running stream is returned as-is. + */ + start: () => Promise; + + /** + * Stop every track and clear the stream. + */ + stop: () => void; + + /** + * Two-way switch mirroring the live state of the stream. Set it to `true` to + * start sharing and `false` to stop. + */ + enabled: ShallowRef; +} + +/** + * @name useDisplayMedia + * @category Media + * @description Reactive `mediaDevices.getDisplayMedia` (screen share) streaming. + * + * @param {UseDisplayMediaOptions} [options={}] Options + * @returns {UseDisplayMediaReturn} `stream`, `start`, `stop`, `enabled` and `isSupported` + * + * @example + * const { stream, enabled, isSupported } = useDisplayMedia(); + * videoEl.srcObject = stream.value ?? null; + * enabled.value = true; // prompts the screen-share picker + * + * @example + * const { start, stop } = useDisplayMedia({ audio: true }); + * await start(); + * + * @since 0.0.15 + */ +export function useDisplayMedia(options: UseDisplayMediaOptions = {}): UseDisplayMediaReturn { + const { + navigator = defaultNavigator, + video = true, + audio = false, + onError = noop, + } = options; + + const enabled = shallowRef(toValue(options.enabled ?? false)); + const stream = shallowRef(); + + const isSupported = useSupported(() => + !!navigator && !!navigator.mediaDevices && !!navigator.mediaDevices.getDisplayMedia); + + // Constraints never change for the lifetime of the composable, so build the + // object once rather than per start() call. + const constraints: MediaStreamConstraints = { audio, video }; + + // Dedupe overlapping start() calls — a single picker prompt is enough. + let startPromise: Promise | undefined; + + function release(): void { + if (!stream.value) + return; + + stream.value.getTracks().forEach(track => track.stop()); + stream.value = undefined; + } + + async function open(): Promise { + if (!isSupported.value || stream.value) + return stream.value; + + if (startPromise) + return startPromise; + + startPromise = navigator!.mediaDevices.getDisplayMedia(constraints) + .then((media) => { + stream.value = media; + // The user can stop sharing from the browser UI; mirror that here. + media.getTracks().forEach(track => + useEventListener(track, 'ended', stop, { passive: true })); + return media; + }) + .catch((error) => { + onError(error); + return undefined; + }) + .finally(() => { + startPromise = undefined; + }); + + return startPromise; + } + + function stop(): void { + release(); + enabled.value = false; + } + + async function start(): Promise { + const media = await open(); + if (media) + enabled.value = true; + + return media; + } + + watch(enabled, (value) => { + if (value) + void open(); + else + release(); + }, { immediate: true }); + + return { + isSupported, + stream, + start, + stop, + enabled, + }; +} diff --git a/vue/toolkit/src/composables/media/useMediaControls/demo.vue b/vue/toolkit/src/composables/media/useMediaControls/demo.vue new file mode 100644 index 0000000..fd464b4 --- /dev/null +++ b/vue/toolkit/src/composables/media/useMediaControls/demo.vue @@ -0,0 +1,146 @@ + + + diff --git a/vue/toolkit/src/composables/media/useMediaControls/index.test.ts b/vue/toolkit/src/composables/media/useMediaControls/index.test.ts new file mode 100644 index 0000000..728cc86 --- /dev/null +++ b/vue/toolkit/src/composables/media/useMediaControls/index.test.ts @@ -0,0 +1,512 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, shallowRef } from 'vue'; +import type { UseMediaControlsReturn } from '.'; +import { useMediaControls } from '.'; + +/** + * A minimal fake HTMLMediaElement backed by a real EventTarget so + * useEventListener can attach/dispatch, with the props the composable touches. + */ +class FakeMediaElement extends EventTarget { + currentTime = 0; + duration = 0; + volume = 1; + muted = false; + playbackRate = 1; + buffered = { length: 0, start: () => 0, end: () => 0 } as unknown as TimeRanges; + textTracks = makeTextTrackList(); + + play = vi.fn(() => Promise.resolve()); + pause = vi.fn(() => {}); + load = vi.fn(() => {}); + requestPictureInPicture = vi.fn(() => Promise.resolve({} as PictureInPictureWindow)); + + // Source/track injection paths + private children: any[] = []; + appendChild = vi.fn((node: any) => { + this.children.push(node); + return node; + }); + + querySelectorAll = vi.fn((selector: string) => { + const tag = selector.toLowerCase(); + return this.children.filter(c => c.tagName?.toLowerCase() === tag); + }); + + emit(type: string): void { + this.dispatchEvent(new Event(type)); + } +} + +function makeTextTrackList(modes: TextTrackMode[] = ['disabled', 'disabled']): TextTrackList { + const list = modes.map((mode, id) => ({ + id, + label: `Track ${id}`, + language: 'en', + mode, + kind: 'subtitles' as TextTrackKind, + inBandMetadataTrackDispatchType: '', + cues: null, + activeCues: null, + })); + const target = new EventTarget(); + return new Proxy(list, { + get(t, prop) { + if (prop === 'length') + return t.length; + if (prop === 'addEventListener' || prop === 'removeEventListener' || prop === 'dispatchEvent') + return (target as any)[prop].bind(target); + return (t as any)[prop]; + }, + }) as unknown as TextTrackList; +} + +function makeDocument(supportsPip = true): Document { + const doc: any = { + createElement: vi.fn((tag: string) => { + const el: any = new EventTarget(); + el.tagName = tag.toUpperCase(); + el.setAttribute = vi.fn(); + return el; + }), + exitPictureInPicture: vi.fn(() => Promise.resolve()), + }; + if (supportsPip) + doc.pictureInPictureEnabled = false; + return doc as Document; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe(useMediaControls, () => { + it('reports Picture-in-Picture support from the document', () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(true), + }); + }); + expect(controls!.supportsPictureInPicture).toBeTruthy(); + scope.stop(); + }); + + it('does not report Picture-in-Picture support when the document lacks it', () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(false), + }); + }); + expect(controls!.supportsPictureInPicture).toBeFalsy(); + scope.stop(); + }); + + it('is SSR-safe when no document is available', () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: undefined, + }); + }); + expect(controls!.supportsPictureInPicture).toBeFalsy(); + // Source injection is skipped without a document. + expect(el.load).not.toHaveBeenCalled(); + scope.stop(); + }); + + it('reflects timeupdate into currentTime without re-seeking', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + el.currentTime = 12.5; + el.emit('timeupdate'); + await nextTick(); + + expect(controls!.currentTime.value).toBe(12.5); + scope.stop(); + }); + + it('seeks the element when currentTime is written', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + controls!.currentTime.value = 42; + await nextTick(); + + expect(el.currentTime).toBe(42); + scope.stop(); + }); + + it('tracks duration, buffered, seeking, ended, stalled, waiting', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + el.duration = 100; + el.emit('durationchange'); + el.buffered = { length: 1, start: () => 0, end: () => 50 } as unknown as TimeRanges; + el.emit('progress'); + el.emit('seeking'); + el.emit('stalled'); + el.emit('ended'); + el.emit('waiting'); + await nextTick(); + + expect(controls!.duration.value).toBe(100); + expect(controls!.buffered.value).toEqual([[0, 50]]); + expect(controls!.seeking.value).toBeTruthy(); + expect(controls!.stalled.value).toBeTruthy(); + expect(controls!.ended.value).toBeTruthy(); + expect(controls!.waiting.value).toBeTruthy(); + + el.emit('seeked'); + el.emit('loadeddata'); + await nextTick(); + expect(controls!.seeking.value).toBeFalsy(); + expect(controls!.waiting.value).toBeFalsy(); + scope.stop(); + }); + + it('plays and pauses through the playing ref', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + controls!.playing.value = true; + await nextTick(); + expect(el.play).toHaveBeenCalled(); + + controls!.playing.value = false; + await nextTick(); + expect(el.pause).toHaveBeenCalled(); + scope.stop(); + }); + + it('reflects play/pause events into playing without re-calling play()', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + el.emit('play'); + await nextTick(); + expect(controls!.playing.value).toBeTruthy(); + expect(el.play).not.toHaveBeenCalled(); + + el.emit('pause'); + await nextTick(); + expect(controls!.playing.value).toBeFalsy(); + expect(el.pause).not.toHaveBeenCalled(); + scope.stop(); + }); + + it('surfaces playback errors via onPlaybackError and onError', async () => { + const el = new FakeMediaElement(); + const boom = new Error('cannot play'); + el.play = vi.fn(() => Promise.reject(boom)); + const onError = vi.fn(); + const onPlayback = vi.fn(); + + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + onError, + }); + }); + controls!.onPlaybackError(onPlayback); + await nextTick(); + + controls!.playing.value = true; + await nextTick(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onPlayback).toHaveBeenCalledWith(boom); + expect(onError).toHaveBeenCalledWith(boom); + scope.stop(); + }); + + it('pushes volume, muted and rate to the element', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + controls!.volume.value = 0.3; + controls!.muted.value = true; + controls!.rate.value = 1.5; + await nextTick(); + + expect(el.volume).toBe(0.3); + expect(el.muted).toBeTruthy(); + expect(el.playbackRate).toBe(1.5); + // playbackRate alias is the same ref as rate + expect(controls!.playbackRate).toBe(controls!.rate); + scope.stop(); + }); + + it('reads volume and muted back from a volumechange event', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + el.volume = 0.8; + el.muted = true; + el.emit('volumechange'); + await nextTick(); + + expect(controls!.volume.value).toBe(0.8); + expect(controls!.muted.value).toBeTruthy(); + scope.stop(); + }); + + it('injects sources and loads the element', async () => { + const el = new FakeMediaElement(); + const document = makeDocument(); + const scope = effectScope(); + scope.run(() => { + useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document, + src: 'https://example.com/clip.mp4', + }); + }); + await nextTick(); + + expect(document.createElement).toHaveBeenCalledWith('source'); + expect(el.appendChild).toHaveBeenCalled(); + expect(el.load).toHaveBeenCalled(); + scope.stop(); + }); + + it('injects a list of sources', async () => { + const el = new FakeMediaElement(); + const document = makeDocument(); + const scope = effectScope(); + scope.run(() => { + useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document, + src: [ + { src: 'a.webm', type: 'video/webm' }, + { src: 'b.mp4', type: 'video/mp4' }, + ], + }); + }); + await nextTick(); + + expect(document.createElement).toHaveBeenCalledTimes(2); + scope.stop(); + }); + + it('injects text tracks and selects the default', async () => { + const el = new FakeMediaElement(); + const document = makeDocument(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document, + tracks: [ + { src: 'en.vtt', srcLang: 'en', label: 'English', kind: 'subtitles' }, + { default: true, src: 'fr.vtt', srcLang: 'fr', label: 'French', kind: 'subtitles' }, + ], + }); + }); + await nextTick(); + + expect(document.createElement).toHaveBeenCalledWith('track'); + expect(controls!.selectedTrack.value).toBe(1); + scope.stop(); + }); + + it('enables and disables text tracks', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + controls!.enableTrack(1); + expect(el.textTracks[1]!.mode).toBe('showing'); + expect(el.textTracks[0]!.mode).toBe('disabled'); + expect(controls!.selectedTrack.value).toBe(1); + + controls!.disableTrack(1); + expect(el.textTracks[1]!.mode).toBe('disabled'); + expect(controls!.selectedTrack.value).toBe(-1); + + controls!.enableTrack(0, false); + expect(el.textTracks[0]!.mode).toBe('showing'); + + controls!.disableTrack(); + expect(el.textTracks[0]!.mode).toBe('disabled'); + expect(controls!.selectedTrack.value).toBe(-1); + scope.stop(); + }); + + it('syncs tracks on addtrack/change/removetrack events', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(), + }); + }); + await nextTick(); + + (el.textTracks as unknown as EventTarget).dispatchEvent(new Event('addtrack')); + await nextTick(); + + expect(controls!.tracks.value).toHaveLength(2); + expect(controls!.tracks.value[0]).toMatchObject({ id: 0, label: 'Track 0', kind: 'subtitles' }); + scope.stop(); + }); + + it('toggles Picture-in-Picture and reflects enter/leave events', async () => { + const el = new FakeMediaElement(); + const document = makeDocument(true); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document, + }); + }); + await nextTick(); + + await controls!.togglePictureInPicture(); + expect(el.requestPictureInPicture).toHaveBeenCalled(); + + el.emit('enterpictureinpicture'); + await nextTick(); + expect(controls!.isPictureInPicture.value).toBeTruthy(); + + await controls!.togglePictureInPicture(); + expect(document.exitPictureInPicture).toHaveBeenCalled(); + + el.emit('leavepictureinpicture'); + await nextTick(); + expect(controls!.isPictureInPicture.value).toBeFalsy(); + scope.stop(); + }); + + it('resolves togglePictureInPicture to a no-op when unsupported', async () => { + const el = new FakeMediaElement(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: makeDocument(false), + }); + }); + await nextTick(); + + await expect(controls!.togglePictureInPicture()).resolves.toBeUndefined(); + expect(el.requestPictureInPicture).not.toHaveBeenCalled(); + scope.stop(); + }); + + it('emits onSourceError when a source errors', async () => { + const el = new FakeMediaElement(); + const created: any[] = []; + const document: any = { + createElement: vi.fn((tag: string) => { + const node = new EventTarget() as any; + node.tagName = tag.toUpperCase(); + node.setAttribute = vi.fn(); + created.push(node); + return node; + }), + exitPictureInPicture: vi.fn(() => Promise.resolve()), + pictureInPictureEnabled: false, + }; + const onSource = vi.fn(); + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(el as unknown as HTMLMediaElement), { + document: document as Document, + src: 'broken.mp4', + }); + }); + controls!.onSourceError(onSource); + await nextTick(); + + created[0].dispatchEvent(new Event('error')); + expect(onSource).toHaveBeenCalled(); + scope.stop(); + }); + + it('does nothing when target is null', async () => { + const scope = effectScope(); + let controls: UseMediaControlsReturn; + scope.run(() => { + controls = useMediaControls(shallowRef(null), { + document: makeDocument(), + }); + }); + await nextTick(); + + expect(controls!.duration.value).toBe(0); + expect(() => controls!.disableTrack()).not.toThrow(); + expect(() => controls!.enableTrack(0)).not.toThrow(); + await expect(controls!.togglePictureInPicture()).resolves.toBeUndefined(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/media/useMediaControls/index.ts b/vue/toolkit/src/composables/media/useMediaControls/index.ts new file mode 100644 index 0000000..f023bad --- /dev/null +++ b/vue/toolkit/src/composables/media/useMediaControls/index.ts @@ -0,0 +1,622 @@ +import { shallowRef, toValue, watch, watchEffect } from 'vue'; +import type { MaybeRefOrGetter, ShallowRef } from 'vue'; +import { isArray, isString, noop } from '@robonen/stdlib'; +import { defaultDocument } from '@/types'; +import type { ConfigurableDocument } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { unrefElement } from '@/composables/component/unrefElement'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { watchIgnorable } from '@/composables/watch/watchIgnorable'; + +/** + * A media `` descriptor injected as a child `` element. + * + * Many of these definitions mirror MDN's HTMLMediaElement documentation. + */ +export interface UseMediaSource { + /** + * The source url for the media + */ + src: string; + + /** + * The media codec type + */ + type?: string; + + /** + * Media query for the resource's intended media + */ + media?: string; +} + +/** + * A text track `` descriptor injected as a child `` element. + */ +export interface UseMediaTextTrackSource { + /** + * Mark the track as enabled unless the user's preferences indicate another is preferred + */ + default?: boolean; + + /** + * How the text track is meant to be used. Defaults to `subtitles` when omitted. + */ + kind: TextTrackKind; + + /** + * A user-readable title used by the browser when listing tracks + */ + label: string; + + /** + * Address of the track (`.vtt` file). Must be same-origin as the document. + */ + src: string; + + /** + * Language of the track text data. A valid BCP 47 language tag. + */ + srcLang: string; +} + +/** + * A reactive snapshot of a single `TextTrack`. + */ +export interface UseMediaTextTrack { + /** + * The index of the text track within the element's `textTracks` + */ + id: number; + + /** + * The text track label + */ + label: string; + + /** + * Language of the track text data (BCP 47) + */ + language: string; + + /** + * The display mode of the text track: `disabled`, `hidden`, or `showing` + */ + mode: TextTrackMode; + + /** + * How the text track is meant to be used + */ + kind: TextTrackKind; + + /** + * The track's in-band metadata track dispatch type + */ + inBandMetadataTrackDispatchType: string; + + /** + * A list of text track cues + */ + cues: TextTrackCueList | null; + + /** + * A list of active text track cues + */ + activeCues: TextTrackCueList | null; +} + +/** + * Subscribe to a media event hook; returns an unsubscribe handle. + */ +export type MediaEventHookOn = (callback: (param: T) => void) => { off: () => void }; + +export interface UseMediaControlsOptions extends ConfigurableDocument { + /** + * The media source(s). A url string, a `UseMediaSource`, or a list of them. + * When provided, matching `` children are injected and the element is reloaded. + */ + src?: MaybeRefOrGetter; + + /** + * Text tracks to inject as `` children + */ + tracks?: MaybeRefOrGetter; + + /** + * Error handler invoked when `play()` or `exitPictureInPicture()` rejects. + * Defaults to a no-op (errors are also surfaced via `onPlaybackError`). + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export interface UseMediaControlsReturn { + /** + * Current playback position in seconds. Writing seeks the media. + */ + currentTime: ShallowRef; + + /** + * Total media duration in seconds (read-only mirror) + */ + duration: ShallowRef; + + /** + * Whether the media is buffering and waiting for more data + */ + waiting: ShallowRef; + + /** + * Whether a seek operation is in progress + */ + seeking: ShallowRef; + + /** + * Whether playback has reached the end of the media + */ + ended: ShallowRef; + + /** + * Whether the browser is trying to fetch data but it is not forthcoming + */ + stalled: ShallowRef; + + /** + * Buffered time ranges as `[start, end]` second pairs + */ + buffered: ShallowRef>; + + /** + * Whether the media is currently playing. Writing toggles play/pause. + */ + playing: ShallowRef; + + /** + * Playback rate (`1` is normal speed). Writing sets `playbackRate`. + */ + rate: ShallowRef; + + /** + * Alias of `rate` for API parity. Writing sets `playbackRate`. + */ + playbackRate: ShallowRef; + + /** + * Audio volume in the range `[0, 1]`. Writing sets the element volume. + */ + volume: ShallowRef; + + /** + * Whether the media is muted. Writing mutes/unmutes the element. + */ + muted: ShallowRef; + + /** + * Reactive snapshot of the element's text tracks + */ + tracks: ShallowRef; + + /** + * The id of the currently selected (`showing`) track, or `-1` when none + */ + selectedTrack: ShallowRef; + + /** + * Enable a track (set to `showing`), optionally disabling all others first. + * + * @param track The track or its id to enable + * @param disableTracks Disable all other tracks first (default `true`) + */ + enableTrack: (track: number | UseMediaTextTrack, disableTracks?: boolean) => void; + + /** + * Disable a track. With no argument, disables every track. + * + * @param track The track or its id to disable + */ + disableTrack: (track?: number | UseMediaTextTrack) => void; + + /** + * Whether the Picture-in-Picture API is available + */ + supportsPictureInPicture: boolean; + + /** + * Toggle Picture-in-Picture for a `