From c8152d3f8ad36cde141e9372248e5977293f4978 Mon Sep 17 00:00:00 2001 From: Ivan Mendez Date: Sat, 21 Feb 2026 20:15:16 +0000 Subject: [PATCH 1/6] chore: bump version to 0.3.1-alpha.0 and update formatCurrency default currency to USD - Change formatCurrency default currency from EUR to USD across all platforms (TypeScript, Kotlin, Dart) - Add validation to throw on NaN/Infinity amounts in formatCurrency (RangeError in TypeScript, IllegalArgumentException in Kotlin, ArgumentError in Dart) - Update all version references from 0.3.0-alpha to 0.3.1-alpha.0 across README, docs, package files, and SECURITY.md - Update formatCurrency examples --- README.md | 8 +- SECURITY.md | 3 +- docs/CHANGELOG.md | 17 +++++ docs/android.md | 2 +- docs/flutter.md | 6 +- docs/getting-started.md | 4 +- docs/roadmap.md | 4 +- docs/web.md | 2 +- lerna.json | 2 +- package-lock.json | 6 +- package.json | 2 +- .../main/kotlin/com/kompkit/core/Format.kt | 20 +++-- .../test/kotlin/com/kompkit/core/CoreTests.kt | 13 +++- packages/core/flutter/CHANGELOG.md | 2 +- packages/core/flutter/README.md | 2 +- packages/core/flutter/lib/src/format.dart | 19 +++-- packages/core/flutter/pubspec.yaml | 2 +- packages/core/flutter/test/format_test.dart | 22 +++++- packages/core/web/README.md | 74 ++++++++++++++----- packages/core/web/package.json | 2 +- packages/core/web/src/format.ts | 19 +++-- packages/core/web/tests/core.test.ts | 11 ++- 22 files changed, 171 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index c2eb1b6..8aa88b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KompKit -[![Version](https://img.shields.io/badge/version-0.3.0--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) +[![Version](https://img.shields.io/badge/version-0.3.1--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) [![Web CI](https://github.com/Kompkit/KompKit/actions/workflows/web.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/web.yml) [![Kotlin CI](https://github.com/Kompkit/KompKit/actions/workflows/android.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/android.yml) [![Flutter CI](https://github.com/Kompkit/KompKit/actions/workflows/flutter.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/flutter.yml) @@ -95,7 +95,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.0-alpha.1 + kompkit_core: ^0.3.1-alpha.0 ``` Then run: @@ -211,7 +211,7 @@ KompKit/ ## Version Information -- **Current Version**: `0.3.0-alpha` +- **Current Version**: `0.3.1-alpha` - **Minimum Requirements**: - Node.js 20+ (Web) - JDK 17+ (Android) @@ -225,7 +225,7 @@ KompKit/ KompKit is currently in **alpha**. This means: - **APIs may change** between alpha versions without a deprecation period. -- **Pin to exact versions** in production: `"kompkit-core": "0.3.0-alpha.1"` / `kompkit_core: 0.3.0-alpha.1`. +- **Pin to exact versions** in production: `"kompkit-core": "0.3.1-alpha.0"` / `kompkit_core: 0.3.1-alpha.0`. - **Breaking changes** will be documented in [CHANGELOG.md](./docs/CHANGELOG.md) with migration notes. - Once `1.0.0` is released, the project will follow strict [Semantic Versioning](https://semver.org/): breaking changes only in major versions. diff --git a/SECURITY.md b/SECURITY.md index c77aaf5..ef0f699 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | | --------------- | ---------------------- | -| `0.3.0-alpha.1` | ✅ Current | +| `0.3.1-alpha.0` | ✅ Current | +| `0.3.0-alpha.1` | ❌ No longer supported | | `0.2.0-alpha.0` | ❌ No longer supported | | `0.1.0-alpha` | ❌ No longer supported | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c8ea1f1..7c03526 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1-alpha.0] - 2026-02-20 + +### Changed + +- **`formatCurrency` default currency**: Changed from `EUR` to `USD` across all platforms (TypeScript, Kotlin, Dart) +- **`formatCurrency` finite validation**: Added validation that throws on `NaN` and `Infinity` amounts across all platforms + - TypeScript: throws `RangeError` + - Kotlin: throws `IllegalArgumentException` + - Dart: throws `ArgumentError` +- **Documentation**: Corrected `formatCurrency` examples to reflect `USD` default +- **Documentation**: Added Platform Differences section to npm README documenting Dart single-argument debounce limitation and other divergences +- **Packaging**: Clarified that `kompkit-core` ships both ESM and CommonJS builds (not ESM-only) + +### Fixed + +- Documentation inconsistency where examples showed `USD` but default was `EUR` + ## [0.3.0-alpha] - 2026-02-09 ### Changed diff --git a/docs/android.md b/docs/android.md index a671568..49d5902 100644 --- a/docs/android.md +++ b/docs/android.md @@ -2,7 +2,7 @@ KompKit Core provides small utilities for Android applications written in Kotlin. -Status: `V0.3.0-alpha`. +Status: `V0.3.1-alpha`. ## Installation diff --git a/docs/flutter.md b/docs/flutter.md index 4dd9e4b..c36fa3a 100644 --- a/docs/flutter.md +++ b/docs/flutter.md @@ -2,7 +2,7 @@ This guide covers using KompKit Core utilities in Flutter and Dart applications. -Status: `V0.3.0-alpha`. +Status: `V0.3.1-alpha`. ## Installation @@ -12,7 +12,7 @@ Add KompKit Core to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.0-alpha.1 + kompkit_core: ^0.3.1-alpha.0 ``` Then run: @@ -29,7 +29,7 @@ For server-side Dart projects, add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.0-alpha.1 + kompkit_core: ^0.3.1-alpha.0 ``` Then run: diff --git a/docs/getting-started.md b/docs/getting-started.md index cba9599..800637a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ KompKit Core is a small cross-platform utility library for Web (TypeScript), Android (Kotlin), and Flutter (Dart). -Status: `V0.3.0-alpha`. +Status: `V0.3.1-alpha`. ## Install @@ -20,7 +20,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.0-alpha.1 + kompkit_core: ^0.3.1-alpha.0 ``` Then run: diff --git a/docs/roadmap.md b/docs/roadmap.md index 659308e..cb34af3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -## Current State — `0.3.0-alpha.1` +## Current State — `0.3.1-alpha.0` KompKit Core is in early alpha. The current release includes: @@ -10,7 +10,7 @@ KompKit Core is in early alpha. The current release includes: - **Full CI/CD** with path-based workflow optimization - **Cancel support** on `debounce` across all platforms -## Next: `0.3.0-alpha` +## Next: `0.3.1-alpha` Focus: utility expansion and Android publishing. diff --git a/docs/web.md b/docs/web.md index 0465445..e6d54af 100644 --- a/docs/web.md +++ b/docs/web.md @@ -2,7 +2,7 @@ KompKit Core provides small, framework-agnostic utilities for web applications written in TypeScript. -Status: `V0.3.0-alpha`. +Status: `V0.3.1-alpha`. ## Installation diff --git a/lerna.json b/lerna.json index bfa9f5a..cfb5400 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.3.0-alpha.1", + "version": "0.3.1-alpha.0", "npmClient": "npm", "packages": ["packages/core/web"], "command": { diff --git a/package-lock.json b/package-lock.json index 3c93cda..1172be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kompkit", - "version": "0.3.0-alpha", + "version": "0.3.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kompkit", - "version": "0.3.0-alpha", + "version": "0.3.1-alpha", "license": "MIT", "workspaces": [ "packages/core/web" @@ -8969,7 +8969,7 @@ }, "packages/core/web": { "name": "kompkit-core", - "version": "0.3.0-alpha.1", + "version": "0.3.1-alpha.0", "license": "MIT", "devDependencies": { "tsup": "^8.5.0", diff --git a/package.json b/package.json index 6c5bf5f..8c4e092 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kompkit", - "version": "0.3.0-alpha", + "version": "0.3.1-alpha", "description": "A lightweight cross-platform utility kit for Web (TypeScript), Android (Kotlin), and Flutter (Dart): debounce, isEmail, formatCurrency", "private": true, "workspaces": [ diff --git a/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt b/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt index 164e30f..224a56b 100644 --- a/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt +++ b/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt @@ -11,28 +11,32 @@ import java.util.Locale * to a [Locale] as required by the JVM — callers do not need to construct [Locale] objects * directly. * - * Throws [IllegalArgumentException] if [currency] is not a valid ISO 4217 code. Locale strings are - * parsed leniently by the JVM; an unrecognized locale falls back to the root locale rather than - * throwing. + * Throws [IllegalArgumentException] if [amount] is not finite (NaN or Infinity), or if [currency] + * is not a valid ISO 4217 code. Locale strings are parsed leniently by the JVM; an unrecognized + * locale falls back to the root locale rather than throwing. * - * @param amount The numeric amount to format. - * @param currency ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR". + * @param amount The numeric amount to format. Must be finite. + * @param currency ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "USD". * @param locale BCP 47 locale string (e.g., "en-US", "es-ES"). Defaults to "en-US". * @return A formatted currency string. - * @throws IllegalArgumentException if [currency] is not a valid ISO 4217 code. + * @throws IllegalArgumentException if [amount] is NaN or Infinity, or if [currency] is not a valid + * ISO 4217 code. * * @sample * ```kotlin - * formatCurrency(1234.56) // "$1,234.56" (en-US default) + * formatCurrency(1234.56) // "$1,234.56" (en-US / USD default) * formatCurrency(1234.56, "EUR", "es-ES") // "1.234,56 €" * formatCurrency(1000.0, "JPY", "ja-JP") // "¥1,000" * ``` */ fun formatCurrency( amount: Double, - currency: String = "EUR", + currency: String = "USD", locale: String = "en-US", ): String { + if (!amount.isFinite()) { + throw IllegalArgumentException("Invalid amount: $amount. Must be a finite number.") + } val jvmLocale = Locale.forLanguageTag(locale) val currencyInstance = runCatching { Currency.getInstance(currency) }.getOrElse { diff --git a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt index e9f9b5e..805e019 100644 --- a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt +++ b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt @@ -42,7 +42,7 @@ class FormatCurrencyTests { fun defaultLocaleIsEnUs() { val result = formatCurrency(1234.56) assertTrue(result.contains("1,234.56"), "Expected en-US formatted number, got: $result") - assertTrue(result.contains("€"), "Expected EUR symbol, got: $result") + assertTrue(result.contains("$"), "Expected USD symbol, got: $result") } @Test @@ -82,6 +82,17 @@ class FormatCurrencyTests { assertFailsWith { formatCurrency(100.0, "INVALID", "en-US") } } + @Test + fun throwsForNaNAmount() { + assertFailsWith { formatCurrency(Double.NaN) } + } + + @Test + fun throwsForInfinityAmount() { + assertFailsWith { formatCurrency(Double.POSITIVE_INFINITY) } + assertFailsWith { formatCurrency(Double.NEGATIVE_INFINITY) } + } + @Test fun unrecognizedLocaleFallsBackGracefully() { // JVM Locale.forLanguageTag is lenient — unknown locales fall back to root locale. diff --git a/packages/core/flutter/CHANGELOG.md b/packages/core/flutter/CHANGELOG.md index 644d0f8..4b0d279 100644 --- a/packages/core/flutter/CHANGELOG.md +++ b/packages/core/flutter/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.3.0-alpha.1 +## 0.3.1-alpha.0 - Initial alpha release of `kompkit_core` for Flutter/Dart - **debounce** — Delay function execution with cancellation (`debounce`, `debounceVoid`) diff --git a/packages/core/flutter/README.md b/packages/core/flutter/README.md index c4047ff..61e13cc 100644 --- a/packages/core/flutter/README.md +++ b/packages/core/flutter/README.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.0-alpha.1 + kompkit_core: ^0.3.1-alpha.0 ``` > Published on [pub.dev/packages/kompkit_core](https://pub.dev/packages/kompkit_core) diff --git a/packages/core/flutter/lib/src/format.dart b/packages/core/flutter/lib/src/format.dart index 5586f5f..f6a79ae 100644 --- a/packages/core/flutter/lib/src/format.dart +++ b/packages/core/flutter/lib/src/format.dart @@ -6,33 +6,36 @@ import 'package:intl/intl.dart'; /// The `intl` package uses underscore-separated locale identifiers internally; /// hyphens are converted automatically. /// -/// Throws [ArgumentError] if [currency] is not a valid ISO 4217 code or -/// [locale] is not a recognized locale — consistent with TypeScript and Kotlin behavior. +/// Throws [ArgumentError] if [amount] is not finite (NaN or Infinity), [currency] is not a valid +/// ISO 4217 code, or [locale] is not a recognized locale — consistent with TypeScript and Kotlin. /// /// **Parameters:** -/// - [amount] - The numeric amount to format (supports both int and double) -/// - [currency] - ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR" +/// - [amount] - The numeric amount to format (supports both int and double). Must be finite. +/// - [currency] - ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "USD" /// - [locale] - BCP 47 locale string (e.g., "en-US", "es-ES", "ja-JP"). Defaults to "en-US" /// /// **Returns:** A formatted currency string according to the specified locale -/// @throws [ArgumentError] if [currency] or [locale] is invalid. +/// @throws [ArgumentError] if [amount] is NaN or Infinity, or if [currency] or [locale] is invalid. /// /// **Example:** /// ```dart -/// formatCurrency(1234.56); // "$1,234.56" (en-US default) +/// formatCurrency(1234.56); // "$1,234.56" (en-US / USD default) /// formatCurrency(1234.56, currency: "EUR", locale: "es-ES"); // "1.234,56 €" /// formatCurrency(1000, currency: "JPY", locale: "ja-JP"); // "¥1,000" /// formatCurrency(999.99, currency: "GBP", locale: "en-GB"); // "£999.99" -/// formatCurrency(-50.25, currency: "USD", locale: "en-US"); // "-$50.25" +/// formatCurrency(double.nan); // throws ArgumentError /// ``` /// ISO 4217 currency codes are exactly 3 uppercase ASCII letters. final _currencyCodeRe = RegExp(r'^[A-Z]{3}$'); String formatCurrency( num amount, { - String currency = "EUR", + String currency = "USD", String locale = "en-US", }) { + if (amount is double && !amount.isFinite) { + throw ArgumentError('Invalid amount: $amount. Must be a finite number.'); + } if (!_currencyCodeRe.hasMatch(currency)) { throw ArgumentError('Invalid currency code: "$currency". Expected a 3-letter ISO 4217 code.'); } diff --git a/packages/core/flutter/pubspec.yaml b/packages/core/flutter/pubspec.yaml index 234b5cb..e58e16a 100644 --- a/packages/core/flutter/pubspec.yaml +++ b/packages/core/flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: kompkit_core description: Cross-platform utility functions for Flutter and Dart applications. Part of the KompKit ecosystem. -version: 0.3.0-alpha.1 +version: 0.3.1-alpha.0 homepage: https://github.com/Kompkit/KompKit repository: https://github.com/Kompkit/KompKit issue_tracker: https://github.com/Kompkit/KompKit/issues diff --git a/packages/core/flutter/test/format_test.dart b/packages/core/flutter/test/format_test.dart index 49db88b..9b454b7 100644 --- a/packages/core/flutter/test/format_test.dart +++ b/packages/core/flutter/test/format_test.dart @@ -3,10 +3,10 @@ import '../lib/src/format.dart'; void main() { group('formatCurrency', () { - test('default locale is en-US with EUR', () { + test('default locale is en-US with USD', () { final result = formatCurrency(1234.56); expect(result, contains('1,234.56')); - expect(result, contains('EUR')); + expect(result, contains('USD')); }); test('formats USD with en-US locale using BCP 47 string', () { @@ -55,6 +55,24 @@ void main() { ); }); + test('throws ArgumentError for NaN amount', () { + expect( + () => formatCurrency(double.nan), + throwsArgumentError, + ); + }); + + test('throws ArgumentError for Infinity amount', () { + expect( + () => formatCurrency(double.infinity), + throwsArgumentError, + ); + expect( + () => formatCurrency(double.negativeInfinity), + throwsArgumentError, + ); + }); + test('throws ArgumentError for invalid locale', () { // intl's NumberFormat throws on unrecognized locales via verifiedLocale(). // This is caught and re-thrown as ArgumentError. diff --git a/packages/core/web/README.md b/packages/core/web/README.md index 28d03f9..163a2f1 100644 --- a/packages/core/web/README.md +++ b/packages/core/web/README.md @@ -1,6 +1,6 @@ # kompkit-core -[![Version](https://img.shields.io/badge/version-0.3.0--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) +[![Version](https://img.shields.io/badge/version-0.3.1--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) [![Web CI](https://github.com/Kompkit/KompKit/actions/workflows/web.yml/badge.svg?branch=release)](https://github.com/Kompkit/KompKit/actions/workflows/web.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) @@ -22,21 +22,22 @@ npm install kompkit-core Delays invoking a function until after `wait` ms have elapsed since the last call. Returns a `Debounced` object with a `cancel()` method. ```ts -import { debounce } from 'kompkit-core'; +import { debounce } from "kompkit-core"; const search = debounce((query: string) => { fetchResults(query); }, 300); -input.addEventListener('input', (e) => search.call(e.target.value)); +input.addEventListener("input", (e) => search.call(e.target.value)); // Cancel a pending call (e.g. on component unmount) search.cancel(); ``` **Signature:** + ```ts -function debounce(action: (value: T) => void, wait?: number): Debounced +function debounce(action: (value: T) => void, wait?: number): Debounced; interface Debounced { call(value: T): void; @@ -53,16 +54,17 @@ interface Debounced { Validates whether a string is a well-formed email address. ```ts -import { isEmail } from 'kompkit-core'; +import { isEmail } from "kompkit-core"; -isEmail('user@example.com') // true -isEmail('not-an-email') // false -isEmail(' user@test.io ') // true (trims whitespace) +isEmail("user@example.com"); // true +isEmail("not-an-email"); // false +isEmail(" user@test.io "); // true (trims whitespace) ``` **Signature:** + ```ts -function isEmail(value: string): boolean +function isEmail(value: string): boolean; ``` --- @@ -72,21 +74,27 @@ function isEmail(value: string): boolean Formats a number as a localized currency string using `Intl.NumberFormat`. ```ts -import { formatCurrency } from 'kompkit-core'; +import { formatCurrency } from "kompkit-core"; -formatCurrency(1234.56) // "$1,234.56" (en-US, EUR default) -formatCurrency(1234.56, 'USD', 'en-US') // "$1,234.56" -formatCurrency(1234.56, 'EUR', 'de-DE') // "1.234,56 €" -formatCurrency(1234.56, 'JPY', 'ja-JP') // "¥1,235" +formatCurrency(1234.56); // "$1,234.56" (en-US / USD default) +formatCurrency(1234.56, "EUR", "de-DE"); // "1.234,56 €" +formatCurrency(1234.56, "JPY", "ja-JP"); // "¥1,235" +formatCurrency(NaN); // throws RangeError ``` **Signature:** + ```ts -function formatCurrency(amount: number, currency?: string, locale?: string): string +function formatCurrency( + amount: number, + currency?: string, + locale?: string, +): string; ``` -- `currency` defaults to `"EUR"` +- `currency` defaults to `"USD"` - `locale` defaults to `"en-US"` (BCP 47 format) +- Throws `RangeError` if `amount` is `NaN` or `Infinity` - Throws `RangeError` for invalid currency codes --- @@ -96,12 +104,38 @@ function formatCurrency(amount: number, currency?: string, locale?: string): str - Node.js `>=20` - TypeScript `>=5.7` (if using types) +## Module format + +This package ships both **ESM and CommonJS** builds. The correct format is resolved automatically via the `exports` field in `package.json`. + +```ts +// ESM (recommended) +import { debounce } from "kompkit-core"; + +// CommonJS +const { debounce } = require("kompkit-core"); +``` + +Node.js `>=20` is required. + +## Platform Differences + +KompKit aims for conceptual API parity, but some differences exist due to platform constraints: + +| Difference | Detail | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `debounce` — Dart | Only supports single-argument functions. TypeScript supports variadic args (`...args`). | +| `debounce` — Kotlin | Requires a `CoroutineScope` parameter (structured concurrency). | +| `formatCurrency` — Kotlin | Accepts a BCP 47 `String` locale, converts to `Locale` internally. | +| `formatCurrency` — invalid locale | TypeScript/V8 and Kotlin/JVM fall back silently; Dart (`intl`) throws. | +| `formatCurrency` — currency validation | TypeScript uses `Intl.NumberFormat`; Kotlin uses `Currency.getInstance`; Dart uses a regex (`^[A-Z]{3}$`). | + ## Cross-platform -| Platform | Package | -|---|---| -| Web (TypeScript) | `npm install kompkit-core` | -| Flutter (Dart) | `flutter pub add kompkit_core` | +| Platform | Package | +| ---------------- | --------------------------------------------------- | +| Web (TypeScript) | `npm install kompkit-core` | +| Flutter (Dart) | `flutter pub add kompkit_core` | | Android (Kotlin) | Local project reference (Maven publish coming soon) | ## Links diff --git a/packages/core/web/package.json b/packages/core/web/package.json index c42232a..c0b5e95 100644 --- a/packages/core/web/package.json +++ b/packages/core/web/package.json @@ -1,6 +1,6 @@ { "name": "kompkit-core", - "version": "0.3.0-alpha.1", + "version": "0.3.1-alpha.0", "description": "Cross-platform utility functions for web applications. Part of the KompKit ecosystem.", "type": "module", "main": "dist/index.cjs", diff --git a/packages/core/web/src/format.ts b/packages/core/web/src/format.ts index 11b4c5c..165b3d7 100644 --- a/packages/core/web/src/format.ts +++ b/packages/core/web/src/format.ts @@ -1,28 +1,31 @@ /** * Formats a number as a localized currency string using BCP 47 locale identifiers. * - * Throws a `RangeError` if `currency` is not a valid ISO 4217 code or `locale` is - * not a valid BCP 47 locale string — consistent with the behavior of `Intl.NumberFormat`. + * Throws a `RangeError` if `amount` is not a finite number, `currency` is not a + * valid ISO 4217 code, or `locale` is not a valid BCP 47 locale string. * - * @param amount - The numeric amount to format. - * @param currency - ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR". + * @param amount - The numeric amount to format. Must be a finite number. + * @param currency - ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "USD". * @param locale - BCP 47 locale string (e.g., "en-US", "es-ES", "ja-JP"). Defaults to "en-US". * @returns A formatted currency string. - * @throws {RangeError} If `currency` or `locale` is invalid. + * @throws {RangeError} If `amount` is NaN or Infinity, or if `currency` or `locale` is invalid. * * @example * ```ts - * formatCurrency(1234.56); // "$1,234.56" (en-US default) + * formatCurrency(1234.56); // "$1,234.56" (en-US / USD default) * formatCurrency(1234.56, "EUR", "es-ES"); // "1.234,56 €" * formatCurrency(1000, "JPY", "ja-JP"); // "¥1,000" - * formatCurrency(NaN, "USD", "en-US"); // "NaN" — Intl.NumberFormat behaviour + * formatCurrency(NaN); // throws RangeError * ``` */ export function formatCurrency( amount: number, - currency = "EUR", + currency = "USD", locale = "en-US", ): string { + if (!Number.isFinite(amount)) { + throw new RangeError(`Invalid amount: ${amount}. Must be a finite number.`); + } return new Intl.NumberFormat(locale, { style: "currency", currency }).format( amount, ); diff --git a/packages/core/web/tests/core.test.ts b/packages/core/web/tests/core.test.ts index ca2e2db..265325a 100644 --- a/packages/core/web/tests/core.test.ts +++ b/packages/core/web/tests/core.test.ts @@ -35,7 +35,7 @@ describe("isEmail", () => { describe("formatCurrency", () => { it("formats USD with en-US locale by default", () => { const result = formatCurrency(1234.56); - expect(result).toBe("€1,234.56"); + expect(result).toBe("$1,234.56"); }); it("formats USD explicitly", () => { @@ -68,6 +68,15 @@ describe("formatCurrency", () => { expect(() => formatCurrency(100, "INVALID", "en-US")).toThrow(RangeError); }); + it("throws RangeError for NaN amount", () => { + expect(() => formatCurrency(NaN)).toThrow(RangeError); + }); + + it("throws RangeError for Infinity amount", () => { + expect(() => formatCurrency(Infinity)).toThrow(RangeError); + expect(() => formatCurrency(-Infinity)).toThrow(RangeError); + }); + it("returns a string for unrecognized locale (V8 silently falls back)", () => { // Intl.NumberFormat in V8 does not throw on unknown locales — it falls back. // This is a known platform behavior difference vs Kotlin/Dart which throw. From c246e5f43ff927a5235ad759b820f4216f57d43e Mon Sep 17 00:00:00 2001 From: Ivan Mendez Date: Sat, 21 Feb 2026 20:29:54 +0000 Subject: [PATCH 2/6] chore: add clamp utility function across all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add clamp(value, min, max) function to TypeScript, Kotlin, and Dart implementations - Validate all arguments are finite numbers and min ≤ max, throwing on invalid input - Add comprehensive unit tests for clamp across all platforms (normal range, edge cases, NaN/Infinity validation) - Update documentation: README feature list, ARCHITECTURE.md API contract, platform guides (web.md, android.md, flutter.md), getting-started.md, and recipes.md --- README.md | 1 + docs/ARCHITECTURE.md | 35 ++++++--- docs/android.md | 17 ++++- docs/flutter.md | 30 ++++++-- docs/getting-started.md | 11 +-- docs/recipes.md | 41 ++++++++++ docs/web.md | 18 ++++- .../src/main/kotlin/com/kompkit/core/Clamp.kt | 27 +++++++ .../test/kotlin/com/kompkit/core/CoreTests.kt | 74 +++++++++++++++++++ packages/core/flutter/README.md | 5 +- packages/core/flutter/lib/kompkit_core.dart | 1 + packages/core/flutter/lib/src/clamp.dart | 29 ++++++++ packages/core/flutter/test/clamp_test.dart | 64 ++++++++++++++++ packages/core/web/README.md | 23 ++++++ packages/core/web/src/clamp.ts | 25 +++++++ packages/core/web/src/index.ts | 1 + packages/core/web/tests/clamp.test.ts | 62 ++++++++++++++++ 17 files changed, 434 insertions(+), 30 deletions(-) create mode 100644 packages/core/android/src/main/kotlin/com/kompkit/core/Clamp.kt create mode 100644 packages/core/flutter/lib/src/clamp.dart create mode 100644 packages/core/flutter/test/clamp_test.dart create mode 100644 packages/core/web/src/clamp.ts create mode 100644 packages/core/web/tests/clamp.test.ts diff --git a/README.md b/README.md index 8aa88b6..8f3772d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ KompKit provides essential utility functions that work seamlessly across Web (Ty - **🕐 debounce** - Delay function execution until after a wait period (prevents excessive API calls) - **📧 isEmail** - Validate email addresses with robust regex patterns - **💰 formatCurrency** - Format numbers as currency with full locale support +- **📐 clamp** - Constrain a number within an inclusive [min, max] range ### Key Features diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 56c6d4f..34f96e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,6 +45,7 @@ All modules implement identical functionality: - **debounce**: Function execution delay with cancellation - **isEmail**: Email validation using regex patterns - **formatCurrency**: Localized currency formatting +- **clamp**: Constrain a number within an inclusive [min, max] range ## API Parity Contract @@ -52,9 +53,9 @@ This section defines the formal contract for cross-platform API consistency in K ### What is Guaranteed -- **Function names** are identical across all platforms (`debounce`, `isEmail`, `formatCurrency`). +- **Function names** are identical across all platforms (`debounce`, `isEmail`, `formatCurrency`, `clamp`). - **Behavioral semantics** are identical: given the same inputs, all platforms produce the same observable output. -- **Default values** are identical: `wait = 250ms`, `currency = "EUR"`, `locale = "en-US"`. +- **Default values** are identical: `wait = 250ms`, `currency = "USD"`, `locale = "en-US"`. - **Error handling philosophy** is consistent: invalid inputs that cannot produce a meaningful result throw/throw-equivalent errors. Silent fallbacks are not permitted. - **Cancel capability**: `debounce` returns an object with a `cancel()` method on all platforms, allowing callers to discard pending executions (required for safe use in component lifecycles). @@ -70,9 +71,10 @@ This section defines the formal contract for cross-platform API consistency in K Every utility follows the same mental model regardless of platform: ``` -debounce(action, options) → Debounced (with .cancel()) -isEmail(value) → Boolean -formatCurrency(amount, options) → String +debounce(action, options) → Debounced (with .cancel()) +isEmail(value) → Boolean +formatCurrency(amount, options) → String +clamp(value, min, max) → Number ``` A developer familiar with the TypeScript API should be able to use the Kotlin or Dart API with only idiomatic adjustments — not conceptual re-learning. @@ -120,9 +122,11 @@ export function isEmail(value: string): boolean; export function formatCurrency( amount: number, - currency?: string, // default: "EUR" + currency?: string, // default: "USD" locale?: string, // default: "en-US" ): string; + +export function clamp(value: number, min: number, max: number): number; ``` **Kotlin:** @@ -143,9 +147,11 @@ fun isEmail(value: String): Boolean fun formatCurrency( amount: Double, - currency: String = "EUR", + currency: String = "USD", locale: String = "en-US", // converted internally to java.util.Locale ): String + +fun clamp(value: Double, min: Double, max: Double): Double ``` **Dart:** @@ -165,9 +171,11 @@ bool isEmail(String value); String formatCurrency( num amount, { - String currency = "EUR", + String currency = "USD", String locale = "en-US", }); + +double clamp(double value, double min, double max); ``` ### Platform-Specific Adaptations @@ -180,6 +188,7 @@ While maintaining API consistency, we leverage platform strengths: - **setTimeout/clearTimeout** for timing control - **Intl.NumberFormat** for currency formatting - **RegExp** for email validation +- **Math.min/Math.max** for clamp #### Kotlin Implementation @@ -187,6 +196,7 @@ While maintaining API consistency, we leverage platform strengths: - **Job cancellation** for timing control - **NumberFormat/Currency** for localized formatting - **Regex** for email validation +- **Double.coerceIn** for clamp #### Dart/Flutter Implementation @@ -194,6 +204,7 @@ While maintaining API consistency, we leverage platform strengths: - **intl package** (`NumberFormat.currency`) for localized formatting - **RegExp** for email validation - **Null safety** with full type-safe APIs +- **num.clamp** for clamp ## Build System Architecture @@ -272,16 +283,18 @@ android.yml: ``` packages/core/web/tests/ -└── core.test.ts # All utility tests +├── core.test.ts # debounce, isEmail, formatCurrency tests +└── clamp.test.ts # clamp unit tests packages/core/android/src/test/kotlin/com/kompkit/core/ -└── CoreTests.kt # All utility tests +└── CoreTests.kt # All utility tests (incl. ClampTests) packages/core/flutter/test/ ├── kompkit_core_test.dart # Integration tests ├── debounce_test.dart # Debounce unit tests ├── validate_test.dart # Validation unit tests -└── format_test.dart # Formatting unit tests +├── format_test.dart # Formatting unit tests +└── clamp_test.dart # Clamp unit tests ``` ### Test Coverage diff --git a/docs/android.md b/docs/android.md index 49d5902..aed4cfb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -30,6 +30,7 @@ dependencies { import com.kompkit.core.debounce import com.kompkit.core.isEmail import com.kompkit.core.formatCurrency +import com.kompkit.core.clamp ``` ## Usage examples @@ -64,10 +65,19 @@ isEmail("invalid@") // false ```kotlin import com.kompkit.core.formatCurrency -import java.util.Locale -formatCurrency(1234.56) // "1.234,56 €" (es-ES by default) -formatCurrency(1234.56, "USD", Locale.US) // "$1,234.56" +formatCurrency(1234.56) // "$1,234.56" (en-US / USD default) +formatCurrency(1234.56, "EUR", "es-ES") // "1.234,56 €" +``` + +### clamp + +```kotlin +import com.kompkit.core.clamp + +clamp(5.0, 0.0, 10.0) // 5.0 +clamp(-3.0, 0.0, 10.0) // 0.0 +clamp(15.0, 0.0, 10.0) // 10.0 ``` ## Jetpack Compose integration @@ -103,4 +113,5 @@ fun SearchBox() { - Requires `kotlinx-coroutines-core` for the `debounce` utility. - All utilities are top-level functions in the `com.kompkit.core` package. +- `formatCurrency` accepts a BCP 47 locale string (e.g., `"en-US"`) — no `java.util.Locale` needed. - Compatible with Android API 21+. diff --git a/docs/flutter.md b/docs/flutter.md index c36fa3a..f2adf72 100644 --- a/docs/flutter.md +++ b/docs/flutter.md @@ -147,17 +147,34 @@ TextFormField( Format numbers as localized currency strings: ```dart -// Default (EUR, es_ES locale) -print(formatCurrency(1234.56)); // "1.234,56 €" +// Default (USD, en-US locale) +print(formatCurrency(1234.56)); // "$1,234.56" -// US Dollar -print(formatCurrency(1234.56, currency: 'USD', locale: 'en_US')); // "$1,234.56" +// Euro +print(formatCurrency(1234.56, currency: 'EUR', locale: 'es-ES')); // "1.234,56 EUR" // Japanese Yen -print(formatCurrency(1000, currency: 'JPY', locale: 'ja_JP')); // "¥1,000" +print(formatCurrency(1000, currency: 'JPY', locale: 'ja-JP')); // "JPY1,000" // British Pound -print(formatCurrency(999.99, currency: 'GBP', locale: 'en_GB')); // "£999.99" +print(formatCurrency(999.99, currency: 'GBP', locale: 'en-GB')); // "GBP999.99" +``` + +### Clamp + +Constrain a number within an inclusive `[min, max]` range: + +```dart +clamp(5.0, 0.0, 10.0) // 5.0 +clamp(-3.0, 0.0, 10.0) // 0.0 +clamp(15.0, 0.0, 10.0) // 10.0 +``` + +Useful for clamping slider values, scroll offsets, or any bounded numeric input: + +```dart +double opacity = clamp(userInput, 0.0, 1.0); +double scrollOffset = clamp(rawOffset, 0.0, maxScrollExtent); ``` #### Flutter Widget Example @@ -312,6 +329,7 @@ KompKit Core for Flutter/Dart works on: - **Debounce**: Uses Dart's `Timer` class for efficient scheduling - **Email Validation**: Compiled regex for fast validation - **Currency Formatting**: Leverages Dart's `intl` package for optimal localization +- **Clamp**: Pure arithmetic — zero overhead ## Next Steps diff --git a/docs/getting-started.md b/docs/getting-started.md index 800637a..4c6240c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,10 +56,11 @@ npm run test ## Utilities -| Utility | Description | -| ---------------- | ------------------------------------------------ | -| `debounce` | Debounce a function call by a delay. | -| `isEmail` | Validate a string with a basic email regex. | -| `formatCurrency` | Format numbers into a localized currency string. | +| Utility | Description | +| ---------------- | -------------------------------------------------------- | +| `debounce` | Debounce a function call by a delay. | +| `isEmail` | Validate a string with a basic email regex. | +| `formatCurrency` | Format numbers into a localized currency string. | +| `clamp` | Constrain a number within an inclusive [min, max] range. | Next: read the detailed guides for [Web](./web.md), [Android](./android.md), [Flutter](./flutter.md), and the [Recipes](./recipes.md). diff --git a/docs/recipes.md b/docs/recipes.md index 6ca6da6..370818d 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -290,6 +290,47 @@ class _PriceDisplayState extends State { } ``` +## Clamping a slider value (TypeScript / React) + +```tsx +import { clamp } from "kompkit-core"; + +function VolumeSlider() { + const [volume, setVolume] = useState(50); + + const handleChange = (raw: number) => { + setVolume(clamp(raw, 0, 100)); + }; + + return ( + handleChange(Number(e.target.value))} + /> + ); +} +``` + +## Clamping a value (Kotlin) + +```kotlin +import com.kompkit.core.clamp + +val volume = clamp(rawInput, 0.0, 100.0) +val opacity = clamp(userValue, 0.0, 1.0) +``` + +## Clamping a value (Flutter) + +```dart +import 'package:kompkit_core/kompkit_core.dart'; + +final volume = clamp(rawInput, 0.0, 100.0); +final opacity = clamp(userValue, 0.0, 1.0); +final scrollOffset = clamp(rawOffset, 0.0, maxScrollExtent); +``` + ## Email validation on form submission (Flutter) ```dart diff --git a/docs/web.md b/docs/web.md index e6d54af..1d5e679 100644 --- a/docs/web.md +++ b/docs/web.md @@ -15,13 +15,13 @@ npm i kompkit-core ESM: ```ts -import { debounce, isEmail, formatCurrency } from "kompkit-core"; +import { debounce, isEmail, formatCurrency, clamp } from "kompkit-core"; ``` CommonJS: ```js -const { debounce, isEmail, formatCurrency } = require("kompkit-core"); +const { debounce, isEmail, formatCurrency, clamp } = require("kompkit-core"); ``` ## Usage examples @@ -54,8 +54,18 @@ isEmail("invalid@"); // false ```ts import { formatCurrency } from "kompkit-core"; -formatCurrency(1234.56); // "1.234,56 €" (es-ES by default) -formatCurrency(1234.56, "USD", "en-US"); // "$1,234.56" +formatCurrency(1234.56); // "$1,234.56" (en-US / USD default) +formatCurrency(1234.56, "EUR", "es-ES"); // "1.234,56 €" +``` + +### clamp + +```ts +import { clamp } from "kompkit-core"; + +clamp(5, 0, 10); // 5 +clamp(-3, 0, 10); // 0 +clamp(15, 0, 10); // 10 ``` ## React snippet diff --git a/packages/core/android/src/main/kotlin/com/kompkit/core/Clamp.kt b/packages/core/android/src/main/kotlin/com/kompkit/core/Clamp.kt new file mode 100644 index 0000000..ec14b94 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/kompkit/core/Clamp.kt @@ -0,0 +1,27 @@ +package com.kompkit.core + +/** + * Constrains a number within the inclusive range [min, max]. + * + * @param value The number to clamp. Must be finite. + * @param min The lower bound (inclusive). Must be finite. + * @param max The upper bound (inclusive). Must be finite. + * @return The clamped value. + * @throws IllegalArgumentException if any argument is not finite, or if min > max. + * + * @sample + * ```kotlin + * clamp(5.0, 0.0, 10.0) // 5.0 + * clamp(-3.0, 0.0, 10.0) // 0.0 + * clamp(15.0, 0.0, 10.0) // 10.0 + * ``` + */ +fun clamp(value: Double, min: Double, max: Double): Double { + require(value.isFinite() && min.isFinite() && max.isFinite()) { + "clamp: all arguments must be finite numbers (got value=$value, min=$min, max=$max)." + } + require(min <= max) { + "clamp: min ($min) must not be greater than max ($max)." + } + return value.coerceIn(min, max) +} diff --git a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt index 805e019..5dca7e6 100644 --- a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt +++ b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt @@ -159,3 +159,77 @@ class DebounceTests { debounced.cancel() // should not throw } } + +class ClampTests { + @Test + fun returnsValueWhenWithinRange() { + assertEquals(5.0, clamp(5.0, 0.0, 10.0)) + } + + @Test + fun returnsMinWhenBelowRange() { + assertEquals(0.0, clamp(-3.0, 0.0, 10.0)) + } + + @Test + fun returnsMaxWhenAboveRange() { + assertEquals(10.0, clamp(15.0, 0.0, 10.0)) + } + + @Test + fun returnsMinWhenValueEqualsMin() { + assertEquals(0.0, clamp(0.0, 0.0, 10.0)) + } + + @Test + fun returnsMaxWhenValueEqualsMax() { + assertEquals(10.0, clamp(10.0, 0.0, 10.0)) + } + + @Test + fun worksWithNegativeRange() { + assertEquals(-5.0, clamp(-5.0, -10.0, -1.0)) + assertEquals(-1.0, clamp(0.0, -10.0, -1.0)) + assertEquals(-10.0, clamp(-20.0, -10.0, -1.0)) + } + + @Test + fun worksWhenMinEqualsMax() { + assertEquals(3.0, clamp(5.0, 3.0, 3.0)) + } + + @Test + fun throwsWhenMinGreaterThanMax() { + assertFailsWith { clamp(5.0, 10.0, 0.0) } + } + + @Test + fun throwsForNaNValue() { + assertFailsWith { clamp(Double.NaN, 0.0, 10.0) } + } + + @Test + fun throwsForNaNMin() { + assertFailsWith { clamp(5.0, Double.NaN, 10.0) } + } + + @Test + fun throwsForNaNMax() { + assertFailsWith { clamp(5.0, 0.0, Double.NaN) } + } + + @Test + fun throwsForInfinityValue() { + assertFailsWith { clamp(Double.POSITIVE_INFINITY, 0.0, 10.0) } + } + + @Test + fun throwsForInfinityMin() { + assertFailsWith { clamp(5.0, Double.POSITIVE_INFINITY, 10.0) } + } + + @Test + fun throwsForInfinityMax() { + assertFailsWith { clamp(5.0, 0.0, Double.POSITIVE_INFINITY) } + } +} diff --git a/packages/core/flutter/README.md b/packages/core/flutter/README.md index 61e13cc..d3b18ea 100644 --- a/packages/core/flutter/README.md +++ b/packages/core/flutter/README.md @@ -26,7 +26,10 @@ final search = debounce((query) => print('Searching: $query'), print(isEmail('user@example.com')); // true // Format currency -print(formatCurrency(1234.56, currency: 'USD', locale: 'en_US')); // "$1,234.56" +print(formatCurrency(1234.56)); // "$1,234.56" (en-US / USD default) + +// Clamp a value +print(clamp(15.0, 0.0, 10.0)); // 10.0 ``` ## Documentation diff --git a/packages/core/flutter/lib/kompkit_core.dart b/packages/core/flutter/lib/kompkit_core.dart index 9b9c255..5860916 100644 --- a/packages/core/flutter/lib/kompkit_core.dart +++ b/packages/core/flutter/lib/kompkit_core.dart @@ -39,3 +39,4 @@ library kompkit_core; export 'src/debounce.dart'; export 'src/validate.dart'; export 'src/format.dart'; +export 'src/clamp.dart'; diff --git a/packages/core/flutter/lib/src/clamp.dart b/packages/core/flutter/lib/src/clamp.dart new file mode 100644 index 0000000..271dd5d --- /dev/null +++ b/packages/core/flutter/lib/src/clamp.dart @@ -0,0 +1,29 @@ +/// Constrains a number within the inclusive range [min, max]. +/// +/// **Parameters:** +/// - [value] - The number to clamp. Must be finite. +/// - [min] - The lower bound (inclusive). Must be finite. +/// - [max] - The upper bound (inclusive). Must be finite. +/// +/// **Returns:** The clamped value. +/// @throws [ArgumentError] if any argument is not finite, or if [min] > [max]. +/// +/// **Example:** +/// ```dart +/// clamp(5.0, 0.0, 10.0) // 5.0 +/// clamp(-3.0, 0.0, 10.0) // 0.0 +/// clamp(15.0, 0.0, 10.0) // 10.0 +/// ``` +double clamp(double value, double min, double max) { + if (!value.isFinite || !min.isFinite || !max.isFinite) { + throw ArgumentError( + 'clamp: all arguments must be finite numbers (got value=$value, min=$min, max=$max).', + ); + } + if (min > max) { + throw ArgumentError( + 'clamp: min ($min) must not be greater than max ($max).', + ); + } + return value.clamp(min, max).toDouble(); +} diff --git a/packages/core/flutter/test/clamp_test.dart b/packages/core/flutter/test/clamp_test.dart new file mode 100644 index 0000000..bc3031e --- /dev/null +++ b/packages/core/flutter/test/clamp_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import '../lib/src/clamp.dart'; + +void main() { + group('clamp', () { + test('returns value when within range', () { + expect(clamp(5.0, 0.0, 10.0), 5.0); + }); + + test('returns min when value is below range', () { + expect(clamp(-3.0, 0.0, 10.0), 0.0); + }); + + test('returns max when value is above range', () { + expect(clamp(15.0, 0.0, 10.0), 10.0); + }); + + test('returns min when value equals min', () { + expect(clamp(0.0, 0.0, 10.0), 0.0); + }); + + test('returns max when value equals max', () { + expect(clamp(10.0, 0.0, 10.0), 10.0); + }); + + test('works with negative range', () { + expect(clamp(-5.0, -10.0, -1.0), -5.0); + expect(clamp(0.0, -10.0, -1.0), -1.0); + expect(clamp(-20.0, -10.0, -1.0), -10.0); + }); + + test('works when min equals max', () { + expect(clamp(5.0, 3.0, 3.0), 3.0); + }); + + test('throws ArgumentError when min > max', () { + expect(() => clamp(5.0, 10.0, 0.0), throwsArgumentError); + }); + + test('throws ArgumentError for NaN value', () { + expect(() => clamp(double.nan, 0.0, 10.0), throwsArgumentError); + }); + + test('throws ArgumentError for NaN min', () { + expect(() => clamp(5.0, double.nan, 10.0), throwsArgumentError); + }); + + test('throws ArgumentError for NaN max', () { + expect(() => clamp(5.0, 0.0, double.nan), throwsArgumentError); + }); + + test('throws ArgumentError for Infinity value', () { + expect(() => clamp(double.infinity, 0.0, 10.0), throwsArgumentError); + }); + + test('throws ArgumentError for Infinity min', () { + expect(() => clamp(5.0, double.infinity, 10.0), throwsArgumentError); + }); + + test('throws ArgumentError for Infinity max', () { + expect(() => clamp(5.0, 0.0, double.infinity), throwsArgumentError); + }); + }); +} diff --git a/packages/core/web/README.md b/packages/core/web/README.md index 163a2f1..f7c373b 100644 --- a/packages/core/web/README.md +++ b/packages/core/web/README.md @@ -69,6 +69,29 @@ function isEmail(value: string): boolean; --- +### `clamp` + +Constrains a number within an inclusive `[min, max]` range. + +```ts +import { clamp } from "kompkit-core"; + +clamp(5, 0, 10); // 5 +clamp(-3, 0, 10); // 0 +clamp(15, 0, 10); // 10 +``` + +**Signature:** + +```ts +function clamp(value: number, min: number, max: number): number; +``` + +- Throws `RangeError` if any argument is `NaN` or `Infinity` +- Throws `RangeError` if `min > max` + +--- + ### `formatCurrency` Formats a number as a localized currency string using `Intl.NumberFormat`. diff --git a/packages/core/web/src/clamp.ts b/packages/core/web/src/clamp.ts new file mode 100644 index 0000000..2dda925 --- /dev/null +++ b/packages/core/web/src/clamp.ts @@ -0,0 +1,25 @@ +/** + * Constrains a number within the inclusive range [min, max]. + * + * @param value - The number to clamp. Must be finite. + * @param min - The lower bound (inclusive). Must be finite. + * @param max - The upper bound (inclusive). Must be finite. + * @returns The clamped value. + * @throws {RangeError} If any argument is not finite, or if `min > max`. + * + * @example + * ```ts + * clamp(5, 0, 10) // 5 + * clamp(-3, 0, 10) // 0 + * clamp(15, 0, 10) // 10 + * ``` + */ +export function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max)) { + throw new RangeError(`clamp: all arguments must be finite numbers (got value=${value}, min=${min}, max=${max}).`); + } + if (min > max) { + throw new RangeError(`clamp: min (${min}) must not be greater than max (${max}).`); + } + return Math.min(Math.max(value, min), max); +} diff --git a/packages/core/web/src/index.ts b/packages/core/web/src/index.ts index 7fb1324..c291925 100644 --- a/packages/core/web/src/index.ts +++ b/packages/core/web/src/index.ts @@ -2,3 +2,4 @@ export * from "./debounce"; export * from "./validate"; export * from "./format"; +export * from "./clamp"; diff --git a/packages/core/web/tests/clamp.test.ts b/packages/core/web/tests/clamp.test.ts new file mode 100644 index 0000000..2b518d4 --- /dev/null +++ b/packages/core/web/tests/clamp.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { clamp } from "../src"; + +describe("clamp", () => { + it("returns value when within range", () => { + expect(clamp(5, 0, 10)).toBe(5); + }); + + it("returns min when value is below range", () => { + expect(clamp(-3, 0, 10)).toBe(0); + }); + + it("returns max when value is above range", () => { + expect(clamp(15, 0, 10)).toBe(10); + }); + + it("returns min when value equals min", () => { + expect(clamp(0, 0, 10)).toBe(0); + }); + + it("returns max when value equals max", () => { + expect(clamp(10, 0, 10)).toBe(10); + }); + + it("works with negative range", () => { + expect(clamp(-5, -10, -1)).toBe(-5); + expect(clamp(0, -10, -1)).toBe(-1); + expect(clamp(-20, -10, -1)).toBe(-10); + }); + + it("works when min equals max", () => { + expect(clamp(5, 3, 3)).toBe(3); + }); + + it("throws RangeError when min > max", () => { + expect(() => clamp(5, 10, 0)).toThrow(RangeError); + }); + + it("throws RangeError for NaN value", () => { + expect(() => clamp(NaN, 0, 10)).toThrow(RangeError); + }); + + it("throws RangeError for NaN min", () => { + expect(() => clamp(5, NaN, 10)).toThrow(RangeError); + }); + + it("throws RangeError for NaN max", () => { + expect(() => clamp(5, 0, NaN)).toThrow(RangeError); + }); + + it("throws RangeError for Infinity value", () => { + expect(() => clamp(Infinity, 0, 10)).toThrow(RangeError); + }); + + it("throws RangeError for Infinity min", () => { + expect(() => clamp(5, Infinity, 10)).toThrow(RangeError); + }); + + it("throws RangeError for Infinity max", () => { + expect(() => clamp(5, 0, Infinity)).toThrow(RangeError); + }); +}); From 661575b2412f44357abb7a2d8238e59805a0975b Mon Sep 17 00:00:00 2001 From: Ivan Mendez Date: Sat, 21 Feb 2026 20:45:57 +0000 Subject: [PATCH 3/6] feat: add throttle utility function across all platforms --- .../main/kotlin/com/kompkit/core/Throttle.kt | 70 ++++++++++++++++++ .../test/kotlin/com/kompkit/core/CoreTests.kt | 72 +++++++++++++++++++ packages/core/flutter/lib/kompkit_core.dart | 1 + packages/core/flutter/lib/src/throttle.dart | 71 ++++++++++++++++++ packages/core/flutter/test/throttle_test.dart | 68 ++++++++++++++++++ packages/core/web/src/index.ts | 1 + packages/core/web/src/throttle.ts | 57 +++++++++++++++ packages/core/web/tests/throttle.test.ts | 69 ++++++++++++++++++ 8 files changed, 409 insertions(+) create mode 100644 packages/core/android/src/main/kotlin/com/kompkit/core/Throttle.kt create mode 100644 packages/core/flutter/lib/src/throttle.dart create mode 100644 packages/core/flutter/test/throttle_test.dart create mode 100644 packages/core/web/src/throttle.ts create mode 100644 packages/core/web/tests/throttle.test.ts diff --git a/packages/core/android/src/main/kotlin/com/kompkit/core/Throttle.kt b/packages/core/android/src/main/kotlin/com/kompkit/core/Throttle.kt new file mode 100644 index 0000000..64c64b3 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/kompkit/core/Throttle.kt @@ -0,0 +1,70 @@ +package com.kompkit.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A throttled function wrapper with a [cancel] method. + * + * Invoke it like a regular function; call [cancel] to reset internal state + * (e.g., on ViewModel `onCleared` or composable disposal). + * + * @param T The type of the argument accepted by the throttled action. + */ +class Throttled(private val invoke: (T) -> Unit) { + /** Invokes the throttled function with [value]. */ + operator fun invoke(value: T) = invoke.invoke(value) + + /** Resets the throttle state, allowing the next call to execute immediately. */ + fun cancel() = _cancel() + + internal var _cancel: () -> Unit = {} +} + +/** + * Throttles consecutive calls so the action executes at most once per [waitMs] milliseconds. + * The first call executes immediately. Subsequent calls within the wait period are ignored. + * After the wait period elapses, the next call executes immediately again. + * + * @param T The type of parameter accepted by the throttled action. + * @param waitMs Milliseconds to suppress subsequent calls after an execution. Must be > 0. + * @param scope Coroutine scope used to schedule the wait period. + * @param action The callback to invoke on each allowed execution. + * @return A [Throttled] wrapper. + * @throws IllegalArgumentException if [waitMs] is not greater than 0. + * + * @sample + * ```kotlin + * val scope = CoroutineScope(Dispatchers.Main) + * val onScroll = throttle(200L, scope) { + * println("scroll event") + * } + * onScroll(Unit) // executes immediately + * onScroll(Unit) // ignored within 200ms + * onScroll.cancel() // resets state + * ``` + */ +fun throttle( + waitMs: Long, + scope: CoroutineScope, + action: (T) -> Unit, +): Throttled { + require(waitMs > 0) { "throttle: waitMs must be greater than 0 (got $waitMs)." } + + var job: Job? = null + val throttled = Throttled { param -> + if (job != null) return@Throttled + action(param) + job = scope.launch { + delay(waitMs) + job = null + } + } + throttled._cancel = { + job?.cancel() + job = null + } + return throttled +} diff --git a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt index 5dca7e6..fb4c1c5 100644 --- a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt +++ b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt @@ -160,6 +160,78 @@ class DebounceTests { } } +class ThrottleTests { + @Test + fun executesImmediatelyOnFirstCall() = runBlocking { + var count = 0 + val scope = this + val throttled = throttle(200L, scope) { count++ } + throttled(Unit) + assertEquals(1, count) + } + + @Test + fun ignoresSubsequentCallsWithinWaitPeriod() = runBlocking { + var count = 0 + val scope = this + val throttled = throttle(200L, scope) { count++ } + throttled(Unit) + throttled(Unit) + throttled(Unit) + assertEquals(1, count) + } + + @Test + fun allowsExecutionAfterWaitPeriodElapses() = runBlocking { + var count = 0 + val scope = this + val throttled = throttle(50L, scope) { count++ } + throttled(Unit) + assertEquals(1, count) + delay(60) + throttled(Unit) + assertEquals(2, count) + } + + @Test + fun passesArgumentsCorrectly() = runBlocking { + var received: String? = null + val scope = this + val throttled = throttle(200L, scope) { received = it } + throttled("hello") + assertEquals("hello", received) + } + + @Test + fun cancelResetsStateSoNextCallExecutesImmediately() = runBlocking { + var count = 0 + val scope = this + val throttled = throttle(200L, scope) { count++ } + throttled(Unit) + assertEquals(1, count) + throttled.cancel() + throttled(Unit) + assertEquals(2, count) + } + + @Test + fun cancelIsSafeWhenNoPendingCall() = runBlocking { + val scope = this + val throttled = throttle(200L, scope) {} + throttled.cancel() // should not throw + } + + @Test + fun throwsWhenWaitIsZero() { + runBlocking { assertFailsWith { throttle(0L, this) {} } } + } + + @Test + fun throwsWhenWaitIsNegative() { + runBlocking { assertFailsWith { throttle(-100L, this) {} } } + } +} + class ClampTests { @Test fun returnsValueWhenWithinRange() { diff --git a/packages/core/flutter/lib/kompkit_core.dart b/packages/core/flutter/lib/kompkit_core.dart index 5860916..780b5e9 100644 --- a/packages/core/flutter/lib/kompkit_core.dart +++ b/packages/core/flutter/lib/kompkit_core.dart @@ -40,3 +40,4 @@ export 'src/debounce.dart'; export 'src/validate.dart'; export 'src/format.dart'; export 'src/clamp.dart'; +export 'src/throttle.dart'; diff --git a/packages/core/flutter/lib/src/throttle.dart b/packages/core/flutter/lib/src/throttle.dart new file mode 100644 index 0000000..74f4690 --- /dev/null +++ b/packages/core/flutter/lib/src/throttle.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +/// A throttled function wrapper with a [cancel] method. +/// +/// Invoke it like the original function; call [cancel] to reset internal state +/// (e.g., in [State.dispose]). +/// +/// ```dart +/// final onScroll = throttle((v) => print('scroll'), Duration(milliseconds: 200)); +/// onScroll(null); // executes immediately +/// onScroll(null); // ignored within 200ms +/// onScroll.cancel(); // resets state +/// ``` +class Throttled { + final void Function(T) _action; + final Duration _wait; + Timer? _timer; + + Throttled._(this._action, this._wait); + + /// Executes [action] immediately if not within the wait period. + /// Calls within the wait period are silently ignored. + void call(T arg) { + if (_timer != null) return; + _action(arg); + _timer = Timer(_wait, () { + _timer = null; + }); + } + + /// Resets the throttle state, allowing the next call to execute immediately. + void cancel() { + _timer?.cancel(); + _timer = null; + } +} + +/// Throttles a function so it executes at most once per [wait] duration. +/// The first call executes immediately. Subsequent calls within the wait period +/// are ignored. After the wait period elapses, the next call executes immediately again. +/// +/// Returns a [Throttled] wrapper that can be called like the original function +/// and supports [Throttled.cancel] for cleanup. +/// +/// **Parameters:** +/// - [fn] - The function to throttle +/// - [wait] - Duration to suppress subsequent calls after an execution +/// +/// **Throws:** [ArgumentError] if [wait] is not greater than [Duration.zero]. +/// +/// **Example:** +/// ```dart +/// final onScroll = throttle((event) { +/// print('Handling: $event'); +/// }, const Duration(milliseconds: 200)); +/// +/// onScroll('a'); // executes immediately +/// onScroll('b'); // ignored within 200ms +/// onScroll.cancel(); // resets state (e.g., in dispose()) +/// ``` +Throttled throttle( + void Function(T) fn, + Duration wait, +) { + if (wait <= Duration.zero) { + throw ArgumentError( + 'throttle: wait must be greater than Duration.zero (got $wait).', + ); + } + return Throttled._(fn, wait); +} diff --git a/packages/core/flutter/test/throttle_test.dart b/packages/core/flutter/test/throttle_test.dart new file mode 100644 index 0000000..53a0257 --- /dev/null +++ b/packages/core/flutter/test/throttle_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import '../lib/src/throttle.dart'; + +void main() { + group('throttle', () { + test('executes the function immediately on first call', () async { + int count = 0; + final throttled = throttle((_) => count++, const Duration(milliseconds: 200)); + throttled(null); + expect(count, 1); + }); + + test('ignores subsequent calls within the wait period', () async { + int count = 0; + final throttled = throttle((_) => count++, const Duration(milliseconds: 200)); + throttled(null); + throttled(null); + throttled(null); + expect(count, 1); + }); + + test('allows execution again after wait period elapses', () async { + int count = 0; + final throttled = throttle((_) => count++, const Duration(milliseconds: 50)); + throttled(null); + expect(count, 1); + await Future.delayed(const Duration(milliseconds: 60)); + throttled(null); + expect(count, 2); + }); + + test('passes arguments correctly', () async { + String? received; + final throttled = throttle((v) => received = v, const Duration(milliseconds: 200)); + throttled('hello'); + expect(received, 'hello'); + }); + + test('cancel() resets state so next call executes immediately', () async { + int count = 0; + final throttled = throttle((_) => count++, const Duration(milliseconds: 200)); + throttled(null); + expect(count, 1); + throttled.cancel(); + throttled(null); + expect(count, 2); + }); + + test('cancel() is safe to call when no call is pending', () { + final throttled = throttle((_) {}, const Duration(milliseconds: 200)); + expect(() => throttled.cancel(), returnsNormally); + }); + + test('throws ArgumentError if wait is Duration.zero', () { + expect( + () => throttle((_) {}, Duration.zero), + throwsArgumentError, + ); + }); + + test('throws ArgumentError if wait is negative', () { + expect( + () => throttle((_) {}, const Duration(milliseconds: -100)), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/core/web/src/index.ts b/packages/core/web/src/index.ts index c291925..45cbd97 100644 --- a/packages/core/web/src/index.ts +++ b/packages/core/web/src/index.ts @@ -3,3 +3,4 @@ export * from "./debounce"; export * from "./validate"; export * from "./format"; export * from "./clamp"; +export * from "./throttle"; diff --git a/packages/core/web/src/throttle.ts b/packages/core/web/src/throttle.ts new file mode 100644 index 0000000..a0a2d96 --- /dev/null +++ b/packages/core/web/src/throttle.ts @@ -0,0 +1,57 @@ +/** + * A throttled function with a cancel method. + * Call it like the original function; call `.cancel()` to reset internal state. + */ +export interface Throttled void> { + (...args: Parameters): void; + /** Resets the throttle state, allowing the next call to execute immediately. */ + cancel(): void; +} + +/** + * Throttles a function so it executes at most once per `wait` milliseconds. + * The first call executes immediately. Subsequent calls within the wait period + * are ignored. After the wait period elapses, the next call executes immediately again. + * + * @param fn - The function to throttle. + * @param wait - Milliseconds to suppress subsequent calls after an execution. + * @returns A throttled wrapper with a `cancel()` method. + * @throws {Error} If `wait` is not greater than 0. + * + * @example + * ```ts + * const onScroll = throttle(() => { + * console.log('scroll event'); + * }, 200); + * + * window.addEventListener('scroll', onScroll); + * onScroll.cancel(); // Reset state (e.g., on component unmount) + * ``` + */ +export function throttle void>( + fn: T, + wait: number, +): Throttled { + if (wait <= 0) { + throw new Error(`throttle: wait must be greater than 0 (got ${wait}).`); + } + + let timer: ReturnType | null = null; + + const throttled = (...args: Parameters): void => { + if (timer !== null) return; + fn(...args); + timer = setTimeout(() => { + timer = null; + }, wait); + }; + + throttled.cancel = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + return throttled as Throttled; +} diff --git a/packages/core/web/tests/throttle.test.ts b/packages/core/web/tests/throttle.test.ts new file mode 100644 index 0000000..5f022c5 --- /dev/null +++ b/packages/core/web/tests/throttle.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { throttle } from "../src"; + +describe("throttle", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("executes the function immediately on first call", () => { + const fn = vi.fn(); + const throttled = throttle(fn, 200); + throttled(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("ignores subsequent calls within the wait period", () => { + const fn = vi.fn(); + const throttled = throttle(fn, 200); + throttled(); + throttled(); + throttled(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("allows execution again after wait period elapses", () => { + const fn = vi.fn(); + const throttled = throttle(fn, 200); + throttled(); + expect(fn).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(200); + throttled(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("passes arguments correctly", () => { + const fn = vi.fn(); + const throttled = throttle(fn, 200); + throttled("hello", 42); + expect(fn).toHaveBeenCalledWith("hello", 42); + }); + + it("cancel() resets state so next call executes immediately", () => { + const fn = vi.fn(); + const throttled = throttle(fn, 200); + throttled(); + expect(fn).toHaveBeenCalledTimes(1); + throttled.cancel(); + throttled(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("cancel() is safe to call when no call is pending", () => { + const fn = vi.fn(); + const throttled = throttle(fn, 200); + expect(() => throttled.cancel()).not.toThrow(); + }); + + it("throws Error if wait <= 0", () => { + expect(() => throttle(() => {}, 0)).toThrow(Error); + }); + + it("throws Error if wait is negative", () => { + expect(() => throttle(() => {}, -100)).toThrow(Error); + }); +}); From 9a7b21f48dffdc62ebfa81291e07ede4f1bb3337 Mon Sep 17 00:00:00 2001 From: Ivan Mendez Date: Sat, 21 Feb 2026 20:52:26 +0000 Subject: [PATCH 4/6] docs: add throttle utility to documentation across all platforms --- README.md | 1 + docs/ARCHITECTURE.md | 46 +++++++++++++++++-- docs/android.md | 20 ++++++++- docs/flutter.md | 23 ++++++++++ docs/getting-started.md | 13 +++--- docs/recipes.md | 78 +++++++++++++++++++++++++++++++++ docs/web.md | 29 +++++++++++- packages/core/flutter/README.md | 5 +++ packages/core/web/README.md | 28 ++++++++++++ 9 files changed, 230 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8f3772d..d5396dc 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ KompKit provides essential utility functions that work seamlessly across Web (Ty - **📧 isEmail** - Validate email addresses with robust regex patterns - **💰 formatCurrency** - Format numbers as currency with full locale support - **📐 clamp** - Constrain a number within an inclusive [min, max] range +- **⏱️ throttle** - Limit a function to execute at most once per wait period ### Key Features diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 34f96e7..1317475 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,7 @@ All modules implement identical functionality: - **isEmail**: Email validation using regex patterns - **formatCurrency**: Localized currency formatting - **clamp**: Constrain a number within an inclusive [min, max] range +- **throttle**: Limit function execution to at most once per wait period ## API Parity Contract @@ -53,7 +54,7 @@ This section defines the formal contract for cross-platform API consistency in K ### What is Guaranteed -- **Function names** are identical across all platforms (`debounce`, `isEmail`, `formatCurrency`, `clamp`). +- **Function names** are identical across all platforms (`debounce`, `isEmail`, `formatCurrency`, `clamp`, `throttle`). - **Behavioral semantics** are identical: given the same inputs, all platforms produce the same observable output. - **Default values** are identical: `wait = 250ms`, `currency = "USD"`, `locale = "en-US"`. - **Error handling philosophy** is consistent: invalid inputs that cannot produce a meaningful result throw/throw-equivalent errors. Silent fallbacks are not permitted. @@ -75,6 +76,7 @@ debounce(action, options) → Debounced (with .cancel()) isEmail(value) → Boolean formatCurrency(amount, options) → String clamp(value, min, max) → Number +throttle(fn, wait) → Throttled (with .cancel()) ``` A developer familiar with the TypeScript API should be able to use the Kotlin or Dart API with only idiomatic adjustments — not conceptual re-learning. @@ -127,6 +129,16 @@ export function formatCurrency( ): string; export function clamp(value: number, min: number, max: number): number; + +export interface Throttled void> { + (...args: Parameters): void; + cancel(): void; +} + +export function throttle void>( + fn: T, + wait: number, // must be > 0 +): Throttled; ``` **Kotlin:** @@ -152,6 +164,17 @@ fun formatCurrency( ): String fun clamp(value: Double, min: Double, max: Double): Double + +class Throttled(private val invoke: (T) -> Unit) { + operator fun invoke(value: T): Unit + fun cancel(): Unit +} + +fun throttle( + waitMs: Long, // must be > 0 + scope: CoroutineScope, // platform constraint: structured concurrency + action: (T) -> Unit, +): Throttled ``` **Dart:** @@ -176,6 +199,16 @@ String formatCurrency( }); double clamp(double value, double min, double max); + +class Throttled { + void call(T arg); + void cancel(); +} + +Throttled throttle( + void Function(T) fn, + Duration wait, // must be > Duration.zero +); ``` ### Platform-Specific Adaptations @@ -189,6 +222,7 @@ While maintaining API consistency, we leverage platform strengths: - **Intl.NumberFormat** for currency formatting - **RegExp** for email validation - **Math.min/Math.max** for clamp +- **setTimeout/clearTimeout** for throttle timer #### Kotlin Implementation @@ -197,6 +231,7 @@ While maintaining API consistency, we leverage platform strengths: - **NumberFormat/Currency** for localized formatting - **Regex** for email validation - **Double.coerceIn** for clamp +- **Coroutine delay + Job** for throttle wait period #### Dart/Flutter Implementation @@ -205,6 +240,7 @@ While maintaining API consistency, we leverage platform strengths: - **RegExp** for email validation - **Null safety** with full type-safe APIs - **num.clamp** for clamp +- **Timer** for throttle scheduling (same as debounce) ## Build System Architecture @@ -284,17 +320,19 @@ android.yml: ``` packages/core/web/tests/ ├── core.test.ts # debounce, isEmail, formatCurrency tests -└── clamp.test.ts # clamp unit tests +├── clamp.test.ts # clamp unit tests +└── throttle.test.ts # throttle unit tests packages/core/android/src/test/kotlin/com/kompkit/core/ -└── CoreTests.kt # All utility tests (incl. ClampTests) +└── CoreTests.kt # All utility tests (incl. ThrottleTests, ClampTests) packages/core/flutter/test/ ├── kompkit_core_test.dart # Integration tests ├── debounce_test.dart # Debounce unit tests ├── validate_test.dart # Validation unit tests ├── format_test.dart # Formatting unit tests -└── clamp_test.dart # Clamp unit tests +├── clamp_test.dart # Clamp unit tests +└── throttle_test.dart # Throttle unit tests ``` ### Test Coverage diff --git a/docs/android.md b/docs/android.md index aed4cfb..5e34693 100644 --- a/docs/android.md +++ b/docs/android.md @@ -31,6 +31,7 @@ import com.kompkit.core.debounce import com.kompkit.core.isEmail import com.kompkit.core.formatCurrency import com.kompkit.core.clamp +import com.kompkit.core.throttle ``` ## Usage examples @@ -80,6 +81,23 @@ clamp(-3.0, 0.0, 10.0) // 0.0 clamp(15.0, 0.0, 10.0) // 10.0 ``` +### throttle + +```kotlin +import com.kompkit.core.throttle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +val scope = CoroutineScope(Dispatchers.Main) +val onScroll = throttle(200L, scope) { + println("scroll event") +} + +onScroll(Unit) // executes immediately +onScroll(Unit) // ignored within 200ms +onScroll.cancel() // reset state +``` + ## Jetpack Compose integration ```kotlin @@ -111,7 +129,7 @@ fun SearchBox() { ## Notes -- Requires `kotlinx-coroutines-core` for the `debounce` utility. +- Requires `kotlinx-coroutines-core` for the `debounce` and `throttle` utilities. - All utilities are top-level functions in the `com.kompkit.core` package. - `formatCurrency` accepts a BCP 47 locale string (e.g., `"en-US"`) — no `java.util.Locale` needed. - Compatible with Android API 21+. diff --git a/docs/flutter.md b/docs/flutter.md index f2adf72..7a29610 100644 --- a/docs/flutter.md +++ b/docs/flutter.md @@ -177,6 +177,28 @@ double opacity = clamp(userInput, 0.0, 1.0); double scrollOffset = clamp(rawOffset, 0.0, maxScrollExtent); ``` +### Throttle + +Limit a function to execute at most once per wait duration. The first call executes immediately; subsequent calls within the wait period are ignored. + +```dart +final onScroll = throttle((_) { + print('scroll event'); +}, const Duration(milliseconds: 200)); + +onScroll(null); // executes immediately +onScroll(null); // ignored within 200ms +onScroll.cancel(); // reset state (e.g. in dispose()) +``` + +Useful for scroll listeners, resize handlers, or any high-frequency event: + +```dart +final onResize = throttle((size) { + setState(() => _size = size); +}, const Duration(milliseconds: 100)); +``` + #### Flutter Widget Example ```dart @@ -330,6 +352,7 @@ KompKit Core for Flutter/Dart works on: - **Email Validation**: Compiled regex for fast validation - **Currency Formatting**: Leverages Dart's `intl` package for optimal localization - **Clamp**: Pure arithmetic — zero overhead +- **Throttle**: Uses Dart's `Timer` class — same mechanism as debounce ## Next Steps diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c6240c..651bdd0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,11 +56,12 @@ npm run test ## Utilities -| Utility | Description | -| ---------------- | -------------------------------------------------------- | -| `debounce` | Debounce a function call by a delay. | -| `isEmail` | Validate a string with a basic email regex. | -| `formatCurrency` | Format numbers into a localized currency string. | -| `clamp` | Constrain a number within an inclusive [min, max] range. | +| Utility | Description | +| ---------------- | --------------------------------------------------------- | +| `debounce` | Debounce a function call by a delay. | +| `isEmail` | Validate a string with a basic email regex. | +| `formatCurrency` | Format numbers into a localized currency string. | +| `clamp` | Constrain a number within an inclusive [min, max] range. | +| `throttle` | Limit a function to execute at most once per wait period. | Next: read the detailed guides for [Web](./web.md), [Android](./android.md), [Flutter](./flutter.md), and the [Recipes](./recipes.md). diff --git a/docs/recipes.md b/docs/recipes.md index 370818d..e65b303 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -290,6 +290,84 @@ class _PriceDisplayState extends State { } ``` +## Throttled scroll handler (TypeScript) + +```ts +import { throttle } from "kompkit-core"; + +const onScroll = throttle(() => { + console.log("scroll position:", window.scrollY); +}, 200); + +window.addEventListener("scroll", onScroll); + +// On cleanup: +onScroll.cancel(); +window.removeEventListener("scroll", onScroll); +``` + +## Throttled event handler (Kotlin) + +```kotlin +import com.kompkit.core.throttle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +val scope = CoroutineScope(Dispatchers.Main) +val onSensorUpdate = throttle(100L, scope) { value -> + updateUI(value) +} + +// In sensor callback: +onSensorUpdate(sensorValue) + +// On cleanup: +onSensorUpdate.cancel() +``` + +## Throttled scroll listener (Flutter) + +```dart +import 'package:flutter/material.dart'; +import 'package:kompkit_core/kompkit_core.dart'; + +class ScrollTracker extends StatefulWidget { + @override + _ScrollTrackerState createState() => _ScrollTrackerState(); +} + +class _ScrollTrackerState extends State { + final _controller = ScrollController(); + late final Throttled _throttledScroll; + + @override + void initState() { + super.initState(); + _throttledScroll = throttle( + (offset) => print('scroll: $offset'), + const Duration(milliseconds: 200), + ); + _controller.addListener(() => _throttledScroll(_controller.offset)); + } + + @override + void dispose() { + _throttledScroll.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: _controller, + itemCount: 100, + itemBuilder: (_, i) => ListTile(title: Text('Item $i')), + ); + } +} +``` + ## Clamping a slider value (TypeScript / React) ```tsx diff --git a/docs/web.md b/docs/web.md index 1d5e679..a793628 100644 --- a/docs/web.md +++ b/docs/web.md @@ -15,13 +15,25 @@ npm i kompkit-core ESM: ```ts -import { debounce, isEmail, formatCurrency, clamp } from "kompkit-core"; +import { + debounce, + isEmail, + formatCurrency, + clamp, + throttle, +} from "kompkit-core"; ``` CommonJS: ```js -const { debounce, isEmail, formatCurrency, clamp } = require("kompkit-core"); +const { + debounce, + isEmail, + formatCurrency, + clamp, + throttle, +} = require("kompkit-core"); ``` ## Usage examples @@ -68,6 +80,19 @@ clamp(-3, 0, 10); // 0 clamp(15, 0, 10); // 10 ``` +### throttle + +```ts +import { throttle } from "kompkit-core"; + +const onScroll = throttle(() => { + console.log("scroll"); +}, 200); + +window.addEventListener("scroll", onScroll); +onScroll.cancel(); // reset state (e.g. on unmount) +``` + ## React snippet ```tsx diff --git a/packages/core/flutter/README.md b/packages/core/flutter/README.md index d3b18ea..cfca5a3 100644 --- a/packages/core/flutter/README.md +++ b/packages/core/flutter/README.md @@ -30,6 +30,11 @@ print(formatCurrency(1234.56)); // "$1,234.56" (en-US / USD default) // Clamp a value print(clamp(15.0, 0.0, 10.0)); // 10.0 + +// Throttle a function +final onScroll = throttle((offset) => print(offset), + const Duration(milliseconds: 200)); +onScroll.cancel(); // reset state ``` ## Documentation diff --git a/packages/core/web/README.md b/packages/core/web/README.md index f7c373b..f9b2bf5 100644 --- a/packages/core/web/README.md +++ b/packages/core/web/README.md @@ -92,6 +92,34 @@ function clamp(value: number, min: number, max: number): number; --- +### `throttle` + +Limits a function to execute at most once per `wait` milliseconds. The first call executes immediately; subsequent calls within the wait period are ignored. + +```ts +import { throttle } from "kompkit-core"; + +const onScroll = throttle(() => { + console.log("scroll:", window.scrollY); +}, 200); + +window.addEventListener("scroll", onScroll); +onScroll.cancel(); // reset state (e.g. on component unmount) +``` + +**Signature:** + +```ts +function throttle void>( + fn: T, + wait: number, +): T & { cancel(): void }; +``` + +- Throws `Error` if `wait <= 0` + +--- + ### `formatCurrency` Formats a number as a localized currency string using `Intl.NumberFormat`. From db6c28583c33199b4ddbe2babffb88251efda6ad Mon Sep 17 00:00:00 2001 From: Ivan Mendez Date: Sat, 21 Feb 2026 21:14:44 +0000 Subject: [PATCH 5/6] docs: expand FAQ with utility details, debounce/throttle differences, and error handling --- docs/faq.md | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index b591420..37e61d0 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,18 +4,55 @@ Utilities provide immediate value across platforms without requiring design decisions or framework-specific implementations. They establish the foundation for cross-platform parity and can be used in any codebase. UI components will be considered in future phases once the utility layer is stable. +## What utilities are available? + +KompKit Core `0.3.1-alpha.0` currently provides: + +- **`debounce`** — Delay function execution until after a wait period +- **`isEmail`** — Validate email addresses with a regex pattern +- **`formatCurrency`** — Format numbers as localized currency strings +- **`clamp`** — Constrain a number within an inclusive `[min, max]` range +- **`throttle`** — Limit a function to execute at most once per wait period + +All utilities are available on Web (TypeScript), Android (Kotlin), and Flutter (Dart) with identical APIs. + +## What is the difference between `debounce` and `throttle`? + +Both limit how often a function runs, but in different ways: + +- **`debounce`** waits until calls have _stopped_ for the wait period before executing. Useful for search inputs — you only want to fire after the user stops typing. +- **`throttle`** executes immediately on the first call, then ignores further calls until the wait period has elapsed. Useful for scroll or resize handlers — you want regular updates but not on every single event. + +## Do `debounce` and `throttle` support cancellation? + +Yes. Both return an object with a `cancel()` method that resets internal state immediately — no pending execution will fire after `cancel()` is called. Always call `cancel()` on component unmount or when the handler is no longer needed to avoid stale callbacks. + +## What happens if I pass `wait <= 0`? + +Both `debounce` and `throttle` throw immediately: + +- **TypeScript**: `Error` +- **Kotlin**: `IllegalArgumentException` +- **Dart**: `ArgumentError` + +`clamp` throws a `RangeError` (TS) / `IllegalArgumentException` (Kotlin) / `ArgumentError` (Dart) if `min > max` or any argument is `NaN` / `Infinity`. + ## Is it tree-shakable? Yes. The web package is built with `tsup` and exports individual functions. Modern bundlers like Webpack, Vite, and Rollup will only include the utilities you import. ## Does it support multiple locales? -Yes. The `formatCurrency` utility accepts a `locale` parameter (e.g., `"en-US"`, `"es-ES"`, `"ja-JP"`). It uses the native `Intl.NumberFormat` API on web and `NumberFormat` on Android, both of which support all standard locales. +Yes. The `formatCurrency` utility accepts a `locale` parameter (e.g., `"en-US"`, `"es-ES"`, `"ja-JP"`). It uses `Intl.NumberFormat` on web, `NumberFormat` on Android, and the `intl` package on Flutter — all of which support standard BCP 47 locale tags. + +## Does the Kotlin package require coroutines? + +Yes. `debounce` and `throttle` both require a `CoroutineScope` to manage their internal timing. This is a deliberate design choice — it integrates with Kotlin's structured concurrency so that pending work is automatically cancelled when the scope is cancelled (e.g., a `viewModelScope` or `lifecycleScope`). `isEmail`, `formatCurrency`, and `clamp` have no coroutine dependency. ## Will there be wrappers for React/Vue later? -Possibly. The current utilities are framework-agnostic and work directly in React, Vue, or vanilla JS. If specific hooks or composables would add value (e.g., `useDebounce`), they may be added as optional packages in the future. +Possibly. The current utilities are framework-agnostic and work directly in React, Vue, or vanilla JS. If specific hooks or composables would add value (e.g., `useDebounce`, `useThrottle`), they may be added as optional packages in the future. ## Is it production-ready? -Not yet. The library is in `0.0.x-alpha` status. APIs may change, and the test coverage is still expanding. Use it in experimental projects, but avoid production deployments until a stable `1.0.0` release. +Not yet. The library is at `0.3.1-alpha.0`. APIs may change, and the test coverage is still expanding. Use it in experimental projects, but avoid production deployments until a stable `1.0.0` release. From fbca9294404d202c20d47e26aa450693ede829ac Mon Sep 17 00:00:00 2001 From: Ivan Mendez Date: Sun, 1 Mar 2026 01:13:51 +0000 Subject: [PATCH 6/6] chore: bump version to 0.4.0-alpha.0 and update documentation with clamp and throttle examples --- README.md | 78 +++++++--- SECURITY.md | 2 +- docs/CHANGELOG.md | 21 ++- docs/android.md | 81 +++++++++-- docs/faq.md | 4 +- docs/flutter.md | 220 +++++++++++++++++++++++------ docs/getting-started.md | 2 +- docs/recipes.md | 123 ++++++++++++++++ docs/roadmap.md | 18 ++- docs/web.md | 125 +++++++++++++--- lerna.json | 2 +- packages/core/flutter/CHANGELOG.md | 7 + packages/core/flutter/README.md | 2 +- packages/core/flutter/pubspec.yaml | 2 +- packages/core/web/README.md | 27 +++- packages/core/web/package.json | 2 +- 16 files changed, 602 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index d5396dc..b74b50f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KompKit -[![Version](https://img.shields.io/badge/version-0.3.1--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) +[![Version](https://img.shields.io/badge/version-0.4.0--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) [![Web CI](https://github.com/Kompkit/KompKit/actions/workflows/web.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/web.yml) [![Kotlin CI](https://github.com/Kompkit/KompKit/actions/workflows/android.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/android.yml) [![Flutter CI](https://github.com/Kompkit/KompKit/actions/workflows/flutter.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/flutter.yml) @@ -97,7 +97,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.1-alpha.0 + kompkit_core: ^0.4.0-alpha.0 ``` Then run: @@ -131,14 +131,32 @@ Once installed, you can import and use KompKit utilities: **TypeScript/JavaScript:** ```typescript -import { debounce, isEmail, formatCurrency } from "kompkit-core"; +import { + debounce, + isEmail, + formatCurrency, + clamp, + throttle, +} from "kompkit-core"; + +// Delay execution until typing stops +const onSearch = debounce( + (query: string) => console.log("Search:", query), + 300, +); + +// Validate email +console.log(isEmail("user@example.com")); // true -const search = debounce((query: string) => { - console.log("Searching:", query); -}, 300); +// Format as currency +console.log(formatCurrency(1234.56)); // "$1,234.56" -console.log(isEmail("user@example.com")); // true -console.log(formatCurrency(1234.56)); // "1.234,56 €" +// Constrain a value to a range +console.log(clamp(15, 0, 10)); // 10 + +// Rate-limit a scroll handler +const onScroll = throttle(() => console.log("scrollY:", window.scrollY), 200); +window.addEventListener("scroll", onScroll); ``` **Kotlin:** @@ -146,12 +164,20 @@ console.log(formatCurrency(1234.56)); // "1.234,56 €" ```kotlin import com.kompkit.core.* -val search = debounce(300L, scope) { query -> - println("Searching: $query") -} +// Delay execution until typing stops +val onSearch = debounce(300L, scope) { query -> println("Search: $query") } +// Validate email println(isEmail("user@example.com")) // true -println(formatCurrency(1234.56)) // "1.234,56 €" + +// Format as currency +println(formatCurrency(1234.56)) // "$1,234.56" + +// Constrain a value to a range +println(clamp(15.0, 0.0, 10.0)) // 10.0 + +// Rate-limit a scroll handler +val onScroll = throttle(200L, scope) { pos -> println("scroll: $pos") } ``` **Dart/Flutter:** @@ -159,12 +185,26 @@ println(formatCurrency(1234.56)) // "1.234,56 €" ```dart import 'package:kompkit_core/kompkit_core.dart'; -final search = debounce((String query) { - print('Searching: $query'); -}, const Duration(milliseconds: 300)); +// Delay execution until typing stops +final onSearch = debounce( + (query) => print('Search: $query'), + const Duration(milliseconds: 300), +); +// Validate email print(isEmail('user@example.com')); // true -print(formatCurrency(1234.56)); // "1.234,56 €" + +// Format as currency +print(formatCurrency(1234.56)); // "$1,234.56" + +// Constrain a value to a range +print(clamp(15.0, 0.0, 10.0)); // 10.0 + +// Rate-limit a scroll handler +final onScroll = throttle( + (offset) => print('scroll: $offset'), + const Duration(milliseconds: 200), +); ``` ## Documentation @@ -213,7 +253,7 @@ KompKit/ ## Version Information -- **Current Version**: `0.3.1-alpha` +- **Current Version**: `0.4.0-alpha.0` - **Minimum Requirements**: - Node.js 20+ (Web) - JDK 17+ (Android) @@ -227,7 +267,7 @@ KompKit/ KompKit is currently in **alpha**. This means: - **APIs may change** between alpha versions without a deprecation period. -- **Pin to exact versions** in production: `"kompkit-core": "0.3.1-alpha.0"` / `kompkit_core: 0.3.1-alpha.0`. +- **Pin to exact versions** in production: `"kompkit-core": "0.4.0-alpha.0"` / `kompkit_core: 0.4.0-alpha.0`. - **Breaking changes** will be documented in [CHANGELOG.md](./docs/CHANGELOG.md) with migration notes. - Once `1.0.0` is released, the project will follow strict [Semantic Versioning](https://semver.org/): breaking changes only in major versions. @@ -239,6 +279,8 @@ KompKit aims for **conceptual parity**, not syntactic identity. The following di | ---------------- | -------- | -------------------------------------------------------------- | ----------------------------------------------------------- | | `debounce` | Kotlin | Requires `CoroutineScope` parameter | Structured concurrency — no global timer API on JVM | | `debounce` | Kotlin | Action is first parameter, scope is last | Enables idiomatic trailing lambda syntax | +| `throttle` | Kotlin | Requires `CoroutineScope` parameter | Same structured concurrency constraint as `debounce` | +| `throttle` | Dart | `wait` is a `Duration`, not a number | Idiomatic Dart — no bare millisecond integers | | `formatCurrency` | Kotlin | Accepts `String` locale, converts to `Locale` internally | JVM `NumberFormat` requires `java.util.Locale` | | `formatCurrency` | Dart | Accepts BCP 47 locale, normalizes hyphen→underscore internally | `intl` package uses underscore-separated locale identifiers | diff --git a/SECURITY.md b/SECURITY.md index ef0f699..5844d37 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | | --------------- | ---------------------- | -| `0.3.1-alpha.0` | ✅ Current | +| `0.4.0-alpha.0` | ✅ Current | | `0.3.0-alpha.1` | ❌ No longer supported | | `0.2.0-alpha.0` | ❌ No longer supported | | `0.1.0-alpha` | ❌ No longer supported | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7c03526..a4c6ee0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,7 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.3.1-alpha.0] - 2026-02-20 +## [0.4.0-alpha.0] - 2026-02-28 + +### Added + +- **`clamp` utility** across all platforms — constrain a number within an inclusive `[min, max]` range + - TypeScript: `clamp(value: number, min: number, max: number): number` + - Kotlin: `clamp(value: Double, min: Double, max: Double): Double` + - Dart: `clamp(double value, double min, double max): double` + - Throws `RangeError` / `IllegalArgumentException` / `ArgumentError` if `min > max` or any argument is non-finite + - Exported from all public entry points; full unit test coverage on all platforms + +- **`throttle` utility** across all platforms — limit a function to execute at most once per wait period + - TypeScript: `throttle(fn: T, wait: number): T & { cancel(): void }` + - Kotlin: `throttle(waitMs: Long, scope: CoroutineScope, action: (T) -> Unit): Throttled` + - Dart: `throttle(void Function(T) fn, Duration wait): Throttled` + - First call executes immediately; subsequent calls within the wait period are ignored + - `cancel()` resets internal state with no pending execution firing + - Throws `Error` / `IllegalArgumentException` / `ArgumentError` if `wait <= 0` + - Exported from all public entry points; full unit test coverage on all platforms ### Changed @@ -16,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TypeScript: throws `RangeError` - Kotlin: throws `IllegalArgumentException` - Dart: throws `ArgumentError` +- **Documentation**: Updated all guides (getting-started, web, android, flutter, ARCHITECTURE, recipes, FAQ) to cover `clamp` and `throttle` - **Documentation**: Corrected `formatCurrency` examples to reflect `USD` default - **Documentation**: Added Platform Differences section to npm README documenting Dart single-argument debounce limitation and other divergences - **Packaging**: Clarified that `kompkit-core` ships both ESM and CommonJS builds (not ESM-only) diff --git a/docs/android.md b/docs/android.md index 5e34693..7d4fdfd 100644 --- a/docs/android.md +++ b/docs/android.md @@ -2,7 +2,7 @@ KompKit Core provides small utilities for Android applications written in Kotlin. -Status: `V0.3.1-alpha`. +Status: `v0.4.0-alpha.0`. ## Installation @@ -76,9 +76,17 @@ formatCurrency(1234.56, "EUR", "es-ES") // "1.234,56 €" ```kotlin import com.kompkit.core.clamp -clamp(5.0, 0.0, 10.0) // 5.0 -clamp(-3.0, 0.0, 10.0) // 0.0 -clamp(15.0, 0.0, 10.0) // 10.0 +clamp(5.0, 0.0, 10.0) // 5.0 — within range, returned as-is +clamp(-3.0, 0.0, 10.0) // 0.0 — below min, clamped to min +clamp(15.0, 0.0, 10.0) // 10.0 — above max, clamped to max +``` + +Useful for bounding any user-controlled numeric value: + +```kotlin +val opacity = clamp(userInput, 0.0, 1.0) +val page = clamp(requestedPage, 1.0, totalPages.toDouble()) +val volume = clamp(rawVolume, 0.0, 100.0) ``` ### throttle @@ -89,21 +97,25 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers val scope = CoroutineScope(Dispatchers.Main) -val onScroll = throttle(200L, scope) { - println("scroll event") +val onScroll = throttle(200L, scope) { position -> + println("scroll position: $position") } -onScroll(Unit) // executes immediately -onScroll(Unit) // ignored within 200ms -onScroll.cancel() // reset state +onScroll(0) // executes immediately +onScroll(50) // ignored within 200ms +onScroll(100) // ignored within 200ms +onScroll.cancel() // reset state — call in onDestroy/onStop ``` +Unlike `debounce` (which waits until calls stop), `throttle` fires immediately then enforces a cooldown — ideal for scroll, sensor, and touch events where you want immediate feedback at a controlled rate. + ## Jetpack Compose integration +### Debounced search field + ```kotlin import androidx.compose.material3.TextField import androidx.compose.runtime.* -import androidx.compose.ui.Modifier import com.kompkit.core.debounce @Composable @@ -127,6 +139,55 @@ fun SearchBox() { } ``` +### Throttled scroll position tracker + +```kotlin +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import com.kompkit.core.throttle + +@Composable +fun ScrollTracker() { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val onScroll = remember { + throttle(200L, scope) { index -> + println("First visible item: $index") + } + } + + LaunchedEffect(listState.firstVisibleItemIndex) { + onScroll(listState.firstVisibleItemIndex) + } + + LazyColumn(state = listState) { + items(100) { index -> + Text("Item $index") + } + } +} +``` + +### Bounded slider with clamp + +```kotlin +import androidx.compose.material3.Slider +import androidx.compose.runtime.* +import com.kompkit.core.clamp + +@Composable +fun VolumeSlider() { + var volume by remember { mutableStateOf(50f) } + + Slider( + value = volume, + onValueChange = { raw -> volume = clamp(raw.toDouble(), 0.0, 100.0).toFloat() }, + valueRange = 0f..100f + ) +} +``` + ## Notes - Requires `kotlinx-coroutines-core` for the `debounce` and `throttle` utilities. diff --git a/docs/faq.md b/docs/faq.md index 37e61d0..ac24b73 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -6,7 +6,7 @@ Utilities provide immediate value across platforms without requiring design deci ## What utilities are available? -KompKit Core `0.3.1-alpha.0` currently provides: +KompKit Core `0.4.0-alpha.0` currently provides: - **`debounce`** — Delay function execution until after a wait period - **`isEmail`** — Validate email addresses with a regex pattern @@ -55,4 +55,4 @@ Possibly. The current utilities are framework-agnostic and work directly in Reac ## Is it production-ready? -Not yet. The library is at `0.3.1-alpha.0`. APIs may change, and the test coverage is still expanding. Use it in experimental projects, but avoid production deployments until a stable `1.0.0` release. +Not yet. The library is at `0.4.0-alpha.0`. APIs may change, and the test coverage is still expanding. Use it in experimental projects, but avoid production deployments until a stable `1.0.0` release. diff --git a/docs/flutter.md b/docs/flutter.md index 7a29610..ba33ffd 100644 --- a/docs/flutter.md +++ b/docs/flutter.md @@ -2,7 +2,7 @@ This guide covers using KompKit Core utilities in Flutter and Dart applications. -Status: `V0.3.1-alpha`. +Status: `v0.4.0-alpha.0`. ## Installation @@ -12,7 +12,7 @@ Add KompKit Core to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.1-alpha.0 + kompkit_core: ^0.4.0-alpha.0 ``` Then run: @@ -29,7 +29,7 @@ For server-side Dart projects, add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.1-alpha.0 + kompkit_core: ^0.4.0-alpha.0 ``` Then run: @@ -170,11 +170,37 @@ clamp(-3.0, 0.0, 10.0) // 0.0 clamp(15.0, 0.0, 10.0) // 10.0 ``` -Useful for clamping slider values, scroll offsets, or any bounded numeric input: +Useful for bounding any user-controlled numeric value: ```dart -double opacity = clamp(userInput, 0.0, 1.0); -double scrollOffset = clamp(rawOffset, 0.0, maxScrollExtent); +final opacity = clamp(userInput, 0.0, 1.0); +final page = clamp(requestedPage, 1.0, totalPages.toDouble()); +final scrollOffset = clamp(rawOffset, 0.0, maxScrollExtent); +``` + +#### Flutter Widget Example + +```dart +class VolumeSlider extends StatefulWidget { + @override + State createState() => _VolumeSliderState(); +} + +class _VolumeSliderState extends State { + double _volume = 50; + + @override + Widget build(BuildContext context) { + return Slider( + value: _volume, + min: 0, + max: 100, + onChanged: (raw) => setState(() { + _volume = clamp(raw, 0.0, 100.0); + }), + ); + } +} ``` ### Throttle @@ -191,6 +217,8 @@ onScroll(null); // ignored within 200ms onScroll.cancel(); // reset state (e.g. in dispose()) ``` +Unlike `debounce` (which waits until calls stop), `throttle` fires immediately then enforces a cooldown — ideal for scroll, sensor, and pointer events where you want immediate feedback at a controlled rate. + Useful for scroll listeners, resize handlers, or any high-frequency event: ```dart @@ -199,7 +227,55 @@ final onResize = throttle((size) { }, const Duration(milliseconds: 100)); ``` -#### Flutter Widget Example +#### Flutter Widget Example — Throttled scroll tracker + +```dart +class ScrollTracker extends StatefulWidget { + @override + State createState() => _ScrollTrackerState(); +} + +class _ScrollTrackerState extends State { + final _controller = ScrollController(); + late final Throttled _onScroll; + double _offset = 0; + + @override + void initState() { + super.initState(); + _onScroll = throttle( + (offset) => setState(() => _offset = offset), + const Duration(milliseconds: 200), + ); + _controller.addListener(() => _onScroll(_controller.offset)); + } + + @override + void dispose() { + _onScroll.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text('Scroll offset: ${_offset.toStringAsFixed(1)}'), + Expanded( + child: ListView.builder( + controller: _controller, + itemCount: 100, + itemBuilder: (_, i) => ListTile(title: Text('Item $i')), + ), + ), + ], + ); + } +} +``` + +#### Flutter Widget Example — formatCurrency display ```dart class PriceDisplay extends StatelessWidget { @@ -251,29 +327,55 @@ class MyApp extends StatelessWidget { class DemoScreen extends StatefulWidget { @override - _DemoScreenState createState() => _DemoScreenState(); + State createState() => _DemoScreenState(); } class _DemoScreenState extends State { final _emailController = TextEditingController(); final _priceController = TextEditingController(); - late final Function(String) _debouncedEmailCheck; + final _scrollController = ScrollController(); + + late final Debounced _debouncedEmailCheck; + late final Throttled _throttledScroll; String _emailStatus = ''; String _formattedPrice = ''; + double _volume = 50; + double _scrollOffset = 0; @override void initState() { super.initState(); - _debouncedEmailCheck = debounce((String email) { + + _debouncedEmailCheck = debounce((email) { setState(() { _emailStatus = isEmail(email) ? 'Valid email ✅' : 'Invalid email ❌'; }); }, const Duration(milliseconds: 300)); + + _throttledScroll = throttle( + (offset) => setState(() => _scrollOffset = offset), + const Duration(milliseconds: 200), + ); + + _scrollController.addListener( + () => _throttledScroll(_scrollController.offset), + ); + } + + @override + void dispose() { + _debouncedEmailCheck.cancel(); + _throttledScroll.cancel(); + _scrollController.dispose(); + _emailController.dispose(); + _priceController.dispose(); + super.dispose(); } void _formatPrice() { - final price = double.tryParse(_priceController.text) ?? 0; + final raw = double.tryParse(_priceController.text) ?? 0; + final price = clamp(raw, 0.0, 1000000.0); setState(() { _formattedPrice = formatCurrency(price, currency: 'USD', locale: 'en_US'); }); @@ -282,39 +384,75 @@ class _DemoScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('KompKit Demo')), - body: Padding( - padding: EdgeInsets.all(16), - child: Column( - children: [ - TextField( - controller: _emailController, - onChanged: _debouncedEmailCheck, - decoration: InputDecoration( - labelText: 'Email', - hintText: 'Enter email address', + appBar: AppBar(title: const Text('KompKit Demo')), + body: Row( + children: [ + // Left panel — form controls + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // debounce: email validation + TextField( + controller: _emailController, + onChanged: _debouncedEmailCheck.call, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'Enter email address', + ), + ), + const SizedBox(height: 8), + Text(_emailStatus), + const SizedBox(height: 24), + + // formatCurrency + clamp: price formatting + TextField( + controller: _priceController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Price (0 – 1,000,000)', + hintText: 'Enter price', + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _formatPrice, + child: const Text('Format as Currency'), + ), + const SizedBox(height: 8), + Text(_formattedPrice, style: const TextStyle(fontSize: 18)), + const SizedBox(height: 24), + + // clamp: volume slider + Text('Volume: ${_volume.toStringAsFixed(0)}'), + Slider( + value: _volume, + min: 0, + max: 100, + onChanged: (raw) => setState(() { + _volume = clamp(raw, 0.0, 100.0); + }), + ), + + // throttle: scroll offset display + const SizedBox(height: 16), + Text('Scroll offset: ${_scrollOffset.toStringAsFixed(1)}'), + ], ), ), - SizedBox(height: 8), - Text(_emailStatus), - SizedBox(height: 24), - TextField( - controller: _priceController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: 'Price', - hintText: 'Enter price', - ), - ), - SizedBox(height: 8), - ElevatedButton( - onPressed: _formatPrice, - child: Text('Format as Currency'), + ), + + // Right panel — scrollable list (throttled scroll tracking) + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: 100, + itemBuilder: (_, i) => ListTile(title: Text('Item $i')), ), - SizedBox(height: 8), - Text(_formattedPrice, style: TextStyle(fontSize: 18)), - ], - ), + ), + ], ), ); } diff --git a/docs/getting-started.md b/docs/getting-started.md index 651bdd0..c58b315 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.1-alpha.0 + kompkit_core: ^0.4.0-alpha.0 ``` Then run: diff --git a/docs/recipes.md b/docs/recipes.md index e65b303..dabb619 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -409,6 +409,129 @@ final opacity = clamp(userValue, 0.0, 1.0); final scrollOffset = clamp(rawOffset, 0.0, maxScrollExtent); ``` +## Throttled scroll header with clamped opacity (TypeScript / React) + +A common pattern: fade in a sticky header as the user scrolls, throttled to avoid running every frame, opacity clamped to stay in `[0, 1]`. + +```tsx +import { useEffect, useRef, useState } from "react"; +import { throttle, clamp } from "kompkit-core"; + +export function StickyHeader() { + const [opacity, setOpacity] = useState(0); + const onScroll = useRef( + throttle(() => { + // Full opacity by 200px, clamped so it never exceeds 1 + setOpacity(clamp(window.scrollY / 200, 0, 1)); + }, 50), + ); + + useEffect(() => { + window.addEventListener("scroll", onScroll.current); + return () => { + onScroll.current.cancel(); + window.removeEventListener("scroll", onScroll.current); + }; + }, []); + + return ( +
+ My App +
+ ); +} +``` + +## Throttled scroll header with clamped opacity (Kotlin / Compose) + +```kotlin +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.lerp +import com.kompkit.core.clamp +import com.kompkit.core.throttle + +@Composable +fun StickyHeader() { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + var opacity by remember { mutableStateOf(0f) } + + val onScroll = remember { + throttle(50L, scope) { offset -> + opacity = clamp(offset / 200.0, 0.0, 1.0).toFloat() + } + } + + LaunchedEffect(listState.firstVisibleItemScrollOffset) { + onScroll(listState.firstVisibleItemScrollOffset) + } + + Box(modifier = Modifier.graphicsLayer { alpha = opacity }) { + Text("My App", style = MaterialTheme.typography.headlineSmall) + } +} +``` + +## Throttled scroll header with clamped opacity (Flutter) + +```dart +import 'package:flutter/material.dart'; +import 'package:kompkit_core/kompkit_core.dart'; + +class StickyHeader extends StatefulWidget { + @override + State createState() => _StickyHeaderState(); +} + +class _StickyHeaderState extends State { + final _controller = ScrollController(); + late final Throttled _onScroll; + double _opacity = 0; + + @override + void initState() { + super.initState(); + _onScroll = throttle( + (offset) => setState(() { + // Full opacity by 200px, clamped to [0, 1] + _opacity = clamp(offset / 200, 0.0, 1.0); + }), + const Duration(milliseconds: 50), + ); + _controller.addListener(() => _onScroll(_controller.offset)); + } + + @override + void dispose() { + _onScroll.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + ListView.builder( + controller: _controller, + itemCount: 100, + itemBuilder: (_, i) => ListTile(title: Text('Item $i')), + ), + Opacity( + opacity: _opacity, + child: Container( + color: Colors.white, + height: 56, + child: const Center(child: Text('My App')), + ), + ), + ], + ); + } +} +``` + ## Email validation on form submission (Flutter) ```dart diff --git a/docs/roadmap.md b/docs/roadmap.md index cb34af3..a5bc6cb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,30 +1,28 @@ # Roadmap -## Current State — `0.3.1-alpha.0` +## Current State — `0.4.0-alpha.0` KompKit Core is in early alpha. The current release includes: -- **3 utilities**: `debounce`, `isEmail`, `formatCurrency` +- **5 utilities**: `debounce`, `isEmail`, `formatCurrency`, `clamp`, `throttle` - **3 platforms**: TypeScript (npm), Dart/Flutter (pub.dev), Kotlin/JVM (local only) - **Conceptual API parity** across all platforms with documented divergences - **Full CI/CD** with path-based workflow optimization -- **Cancel support** on `debounce` across all platforms +- **Cancel support** on `debounce` and `throttle` across all platforms -## Next: `0.3.1-alpha` +## Next: `0.5.0-alpha` -Focus: utility expansion and Android publishing. +Focus: async utilities and Android publishing. -- `throttle` — Rate-limit function calls (natural companion to `debounce`) -- `clamp` — Clamp a number between min and max - `sleep` — Promise/Future/suspend-based delay utility +- `retry` — Retry failed async operations with configurable backoff - Publish Android/Kotlin package to Maven Central - Add `exports` subpath entries for individual utilities (tree-shaking improvement) -## After: `0.4.0-alpha` +## After: `0.6.0-alpha` -Focus: async utilities and deeper platform integration. +Focus: data utilities and deeper platform integration. -- `retry` — Retry failed async operations with configurable backoff - `deepEqual` — Deep equality comparison for plain objects/maps - React hooks companion package (`kompkit-react`) — thin wrappers over core utilities diff --git a/docs/web.md b/docs/web.md index a793628..3ba3de1 100644 --- a/docs/web.md +++ b/docs/web.md @@ -2,7 +2,7 @@ KompKit Core provides small, framework-agnostic utilities for web applications written in TypeScript. -Status: `V0.3.1-alpha`. +Status: `v0.4.0-alpha.0`. ## Installation @@ -75,9 +75,17 @@ formatCurrency(1234.56, "EUR", "es-ES"); // "1.234,56 €" ```ts import { clamp } from "kompkit-core"; -clamp(5, 0, 10); // 5 -clamp(-3, 0, 10); // 0 -clamp(15, 0, 10); // 10 +clamp(5, 0, 10); // 5 — within range, returned as-is +clamp(-3, 0, 10); // 0 — below min, clamped to min +clamp(15, 0, 10); // 10 — above max, clamped to max +``` + +Useful for bounding any user-controlled numeric value: + +```ts +const opacity = clamp(userInput, 0, 1); +const page = clamp(requestedPage, 1, totalPages); +const volume = clamp(rawVolume, 0, 100); ``` ### throttle @@ -85,45 +93,101 @@ clamp(15, 0, 10); // 10 ```ts import { throttle } from "kompkit-core"; -const onScroll = throttle(() => { - console.log("scroll"); +const onScroll = throttle((e: Event) => { + console.log("scrollY:", window.scrollY); }, 200); window.addEventListener("scroll", onScroll); -onScroll.cancel(); // reset state (e.g. on unmount) + +// Always clean up to avoid stale handlers: +onScroll.cancel(); +window.removeEventListener("scroll", onScroll); ``` -## React snippet +Unlike `debounce` (which delays until calls stop), `throttle` fires immediately then enforces a cooldown — ideal for scroll, resize, and pointer events where you want immediate feedback but not every frame. + +## React snippets + +### debounced search input ```tsx -import { useState } from "react"; +import { useEffect, useRef } from "react"; import { debounce } from "kompkit-core"; export function SearchBox() { - const [value, setValue] = useState(""); - const run = debounce((v: string) => console.log("search", v), 250); + const onSearch = useRef( + debounce((v: string) => console.log("search", v), 300), + ); + + useEffect(() => () => onSearch.current.cancel(), []); + return ( { - setValue(e.target.value); - run(e.target.value); - }} + onChange={(e) => onSearch.current(e.target.value)} placeholder="Search" /> ); } ``` -## Vue snippet +### throttled scroll tracker + +```tsx +import { useEffect, useRef } from "react"; +import { throttle } from "kompkit-core"; + +export function ScrollTracker() { + const onScroll = useRef( + throttle(() => { + console.log("scrollY:", window.scrollY); + }, 200), + ); + + useEffect(() => { + window.addEventListener("scroll", onScroll.current); + return () => { + onScroll.current.cancel(); + window.removeEventListener("scroll", onScroll.current); + }; + }, []); + + return
Scroll the page
; +} +``` + +### bounded slider with clamp + +```tsx +import { useState } from "react"; +import { clamp } from "kompkit-core"; + +export function VolumeSlider() { + const [volume, setVolume] = useState(50); + + return ( + setVolume(clamp(Number(e.target.value), 0, 100))} + /> + ); +} +``` + +## Vue snippets + +### debounced search ```vue ``` +### throttled scroll handler + +```vue + + + +``` + ## Notes - Framework-agnostic: works with React, Vue, or any TS/JS app. diff --git a/lerna.json b/lerna.json index cfb5400..ce26e8e 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.3.1-alpha.0", + "version": "0.4.0-alpha.0", "npmClient": "npm", "packages": ["packages/core/web"], "command": { diff --git a/packages/core/flutter/CHANGELOG.md b/packages/core/flutter/CHANGELOG.md index 4b0d279..c46397a 100644 --- a/packages/core/flutter/CHANGELOG.md +++ b/packages/core/flutter/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.0-alpha.0 + +- **clamp** — Constrain a number within an inclusive `[min, max]` range +- **throttle** — Limit a function to execute at most once per wait period (`Throttled` with `cancel()`) +- **formatCurrency** default currency changed from `EUR` to `USD` +- Documentation updated across all guides + ## 0.3.1-alpha.0 - Initial alpha release of `kompkit_core` for Flutter/Dart diff --git a/packages/core/flutter/README.md b/packages/core/flutter/README.md index cfca5a3..9f38391 100644 --- a/packages/core/flutter/README.md +++ b/packages/core/flutter/README.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.3.1-alpha.0 + kompkit_core: ^0.4.0-alpha.0 ``` > Published on [pub.dev/packages/kompkit_core](https://pub.dev/packages/kompkit_core) diff --git a/packages/core/flutter/pubspec.yaml b/packages/core/flutter/pubspec.yaml index e58e16a..e4b2662 100644 --- a/packages/core/flutter/pubspec.yaml +++ b/packages/core/flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: kompkit_core description: Cross-platform utility functions for Flutter and Dart applications. Part of the KompKit ecosystem. -version: 0.3.1-alpha.0 +version: 0.4.0-alpha.0 homepage: https://github.com/Kompkit/KompKit repository: https://github.com/Kompkit/KompKit issue_tracker: https://github.com/Kompkit/KompKit/issues diff --git a/packages/core/web/README.md b/packages/core/web/README.md index f9b2bf5..f67bebd 100644 --- a/packages/core/web/README.md +++ b/packages/core/web/README.md @@ -1,6 +1,6 @@ # kompkit-core -[![Version](https://img.shields.io/badge/version-0.3.1--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) +[![Version](https://img.shields.io/badge/version-0.4.0--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) [![Web CI](https://github.com/Kompkit/KompKit/actions/workflows/web.yml/badge.svg?branch=release)](https://github.com/Kompkit/KompKit/actions/workflows/web.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) @@ -76,9 +76,17 @@ Constrains a number within an inclusive `[min, max]` range. ```ts import { clamp } from "kompkit-core"; -clamp(5, 0, 10); // 5 -clamp(-3, 0, 10); // 0 -clamp(15, 0, 10); // 10 +clamp(5, 0, 10); // 5 — within range, returned as-is +clamp(-3, 0, 10); // 0 — below min, clamped to min +clamp(15, 0, 10); // 10 — above max, clamped to max +``` + +Useful for bounding any user-controlled numeric value: + +```ts +const opacity = clamp(userInput, 0, 1); +const page = clamp(requestedPage, 1, totalPages); +const volume = clamp(rawVolume, 0, 100); ``` **Signature:** @@ -99,14 +107,19 @@ Limits a function to execute at most once per `wait` milliseconds. The first cal ```ts import { throttle } from "kompkit-core"; -const onScroll = throttle(() => { - console.log("scroll:", window.scrollY); +const onScroll = throttle((e: Event) => { + console.log("scrollY:", window.scrollY); }, 200); window.addEventListener("scroll", onScroll); -onScroll.cancel(); // reset state (e.g. on component unmount) + +// Always clean up to avoid stale handlers: +onScroll.cancel(); +window.removeEventListener("scroll", onScroll); ``` +Unlike `debounce` (which waits until calls stop), `throttle` fires immediately then enforces a cooldown — ideal for scroll, resize, and pointer events. + **Signature:** ```ts diff --git a/packages/core/web/package.json b/packages/core/web/package.json index c0b5e95..4b4972b 100644 --- a/packages/core/web/package.json +++ b/packages/core/web/package.json @@ -1,6 +1,6 @@ { "name": "kompkit-core", - "version": "0.3.1-alpha.0", + "version": "0.4.0-alpha.0", "description": "Cross-platform utility functions for web applications. Part of the KompKit ecosystem.", "type": "module", "main": "dist/index.cjs",