diff --git a/CHANGELOG.md b/CHANGELOG.md index 456a8f6..9b13015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,48 +1,76 @@ # Changelog -All notable changes to this project are documented in this file. +Todas as mudanças relevantes deste projeto são documentadas aqui. -This project follows Semantic Versioning. +Este projeto segue o Versionamento Semântico. + +## [0.2.0] - 2026-02-19 + +### Adicionado + +- Modo headless: passar `null` (ou `undefined`) como `target` executa a animação sem nenhum elemento DOM. Os valores são entregues exclusivamente pelo callback `onUpdate`. Permite uso em Node.js, frameworks SSR (Next.js, Nuxt, Remix) e ambientes de teste sem jsdom. +- Adicionada opção `sleep` (número em ms): tempo de espera antes de a animação iniciar. `0` (padrão) mantém o comportamento atual. O timer é cancelado automaticamente por `stop()`, `pause()` e `destroy()`. Útil para escalonar múltiplos contadores sem `setTimeout` manual. +- Adicionado getter `waiting` nas instâncias: retorna `true` enquanto o `sleep` estiver pendente. +- `end` agora é opcional quando um elemento DOM é fornecido. A biblioteca lê o `element.textContent`, remove caracteres não numéricos e usa o valor parseado como destino da animação — um valor renderizado pelo servidor (ex.: PHP) já é suficiente, sem configuração extra. +- `decimals` agora é inferido automaticamente do texto do elemento quando ambos `end` e `decimals` são omitidos (ex.: elemento com `"15.50"` define `decimals: 2` automaticamente). +- Adicionadas declarações TypeScript nativas (`src/counterup.d.ts`): tipos completos para `CounterUpOptions`, `CounterUpInstance`, `CounterUpGroupInstance`, `CounterUpTarget`, `EasingFunction`, `FormatterFunction` e `CounterUpCallback`, com overloads precisos que diferenciam instância única (target `null` ou `Element`) de instância de grupo (seletor, `NodeList`, array). Sem dependência de `@types/*`. + +### Corrigido + +- Corrigido `ReferenceError: requestAnimationFrame is not defined` em ambientes sem browser, introduzindo polyfills internos `raf`/`caf` que usam `setTimeout`/`clearTimeout` com `Date.now()` como timestamp. +- Corrigido `ReferenceError: document is not defined` ao passar um seletor CSS string em ambientes sem DOM. Agora é lançado um erro claro e acionável. +- Corrigido `TypeError: Cannot set properties of undefined` no wrapper UMD quando carregado como ES module em Node.js (pacotes com `"type": "module"`). O contexto global agora usa `globalThis` como alvo primário, com `window` e `this` como fallback. +- Corrigido `TypeError: Cannot set property 'textContent' of null` quando `element` é `null` em modo headless. A função `render` agora protege a escrita no DOM. +- Corrigido `setupObserver` tentando chamar `IntersectionObserver.observe(null)` em modo headless. A configuração do observer agora é ignorada quando não há elemento. + +### Alterado + +- O global UMD agora é `counterUp` (a função diretamente) em vez de `CounterUp` (um objeto namespace). O uso via ` ``` +--- + ## API ### `counterUp(target, options)` -`target`: seletor CSS, elemento DOM, `NodeList` ou array de elementos. +**`target`** — o que animar: + +| Tipo | Exemplo | Comportamento | +|---|---|---| +| `string` | `"#id"`, `".classe"` | Seleciona via `document.querySelectorAll` | +| `Element` | `document.getElementById("x")` | Usa o elemento diretamente | +| `NodeList` / `HTMLCollection` | `document.querySelectorAll(".x")` | Anima todos os elementos | +| `Element[]` | `[el1, el2]` | Anima todos os elementos do array | +| `null` / `undefined` | `null` | **Modo headless** — sem DOM, use `onUpdate` | + +--- + +### Opções + +#### Valores + +| Opção | Tipo | Padrão | Descrição | +|---|---|---|---| +| `start` | `number` | `0` | Número de onde a animação parte. O contador começa exibindo este valor. | +| `end` | `number` | auto | Número até onde a animação conta. **Quando omitido**, a biblioteca lê o `textContent` do elemento e usa esse valor como destino — ou seja, o valor já renderizado no HTML é suficiente. Necessário em modo headless. | +| `duration` | `number` | `2000` | Tempo total da animação em milissegundos. `0` pula diretamente para o valor de `end`. | + +#### Formatação + +| Opção | Tipo | Padrão | Descrição | +|---|---|---|---| +| `decimals` | `number` | auto | Quantidade de casas decimais exibidas. **Quando omitido** junto com `end`, é inferido automaticamente do `textContent` do elemento (ex.: `"15.50"` → `2`). | +| `prefix` | `string` | `""` | Texto adicionado **antes** do número (ex.: `"R$ "`, `"$"`). | +| `suffix` | `string` | `""` | Texto adicionado **depois** do número (ex.: `"%"`, `" pts"`). | +| `locale` | `string` | `"pt-BR"` | Locale para `Intl.NumberFormat`. Controla separadores decimais e de milhar (ex.: `"en-US"`, `"de-DE"`). | +| `useGrouping` | `boolean` | `true` | Exibe separador de milhar conforme o locale (`1.000` vs `1000`). | +| `formatter` | `function \| null` | `null` | Função de formatação personalizada. Substitui toda a lógica de formatação padrão. Recebe `(value, element, index)` e deve retornar uma string. | + +#### Animação + +| Opção | Tipo | Padrão | Descrição | +|---|---|---|---| +| `easing` | `string \| function` | `"easeOutCubic"` | Curva de aceleração da animação. Strings aceitas: `"linear"`, `"easeInOutQuad"`, `"easeOutCubic"`. Também aceita uma função `(t: number) => number` onde `t` vai de `0` a `1`. | + +#### Comportamento + +| Opção | Tipo | Padrão | Descrição | +|---|---|---|---| +| `sleep` | `number` | `0` | Tempo de espera em milissegundos antes de a animação começar. `0` inicia imediatamente. Útil para escalonar múltiplos contadores ou aguardar após um elemento entrar na viewport. O sleep é cancelado se `.stop()`, `.pause()` ou `.destroy()` for chamado antes de ele disparar. | +| `autostart` | `boolean` | `true` | Inicia a animação automaticamente ao criar a instância. Se `false`, a animação fica aguardando uma chamada manual a `.start()`. | +| `startOnView` | `boolean` | `false` | Usa `IntersectionObserver` para iniciar a animação somente quando o elemento entra na viewport. Ignorado em modo headless (sem DOM). | +| `once` | `boolean` | `true` | Usado com `startOnView`: se `true`, a animação dispara apenas na primeira vez que o elemento aparecer. Se `false`, reinicia toda vez que o elemento entrar na viewport. | + +#### IntersectionObserver (usado com `startOnView`) + +| Opção | Tipo | Padrão | Descrição | +|---|---|---|---| +| `root` | `Element \| null` | `null` | Elemento raiz do `IntersectionObserver`. `null` usa o viewport da janela. | +| `rootMargin` | `string` | `"0px"` | Margem ao redor do root, no formato CSS (ex.: `"0px 0px -100px 0px"`). Permite disparar antes ou depois do elemento estar completamente visível. | +| `threshold` | `number \| number[]` | `0.1` | Fração do elemento que precisa estar visível para disparar. `0.1` = 10%, `1` = 100% visível. | + +#### Callbacks + +| Opção | Tipo | Descrição | +|---|---|---| +| `onUpdate` | `function \| null` | Chamado a cada frame da animação com `(value, element, index)`. `element` é `null` em modo headless. | +| `onComplete` | `function \| null` | Chamado uma vez quando a animação termina com `(value, element, index)`. `element` é `null` em modo headless. | + +--- + +### Instância — elemento único + +#### Métodos + +| Método | Descrição | +|---|---| +| `.start()` | Inicia a animação. Se estiver pausada, retoma do ponto onde parou. Se já estiver rodando, não faz nada. | +| `.pause()` | Pausa a animação preservando o progresso atual. | +| `.resume()` | Retoma a animação do ponto em que foi pausada. | +| `.stop()` | Para a animação e reseta o progresso interno (mas não o valor exibido). | +| `.reset()` | Para a animação e volta o valor exibido para `start`. | +| `.set(value)` | Define o valor exibido diretamente, sem animação. Para qualquer animação em curso. | +| `.update(nextEnd, nextOptions?)` | Muda o valor final (e opcionalmente outras opções) e reinicia a animação do valor atual. | +| `.destroy()` | Para a animação, desconecta o observer e marca a instância como destruída. Chamadas subsequentes são ignoradas. | + +#### Getters + +| Getter | Tipo | Descrição | +|---|---|---| +| `.value` | `number` | Valor numérico atual (sem formatação). | +| `.running` | `boolean` | `true` se a animação estiver em execução. | +| `.paused` | `boolean` | `true` se a animação estiver pausada. | +| `.waiting` | `boolean` | `true` se a animação estiver aguardando o `sleep` disparar. | -`options`: +--- -- `start` (number, padrão `0`) -- `end` (number, padrão `100`) -- `duration` (number em ms, padrão `2000`) -- `decimals` (number, padrão `0`) -- `prefix` (string) -- `suffix` (string) -- `locale` (string, padrão `pt-BR`) -- `useGrouping` (boolean, padrão `true`) -- `easing` (`"linear"` | `"easeInOutQuad"` | `"easeOutCubic"` | function) -- `formatter` (function) -- `autostart` (boolean, padrão `true`) -- `startOnView` (boolean, padrão `false`): inicia quando o elemento entra na viewport -- `once` (boolean, padrão `true`): com `startOnView`, anima apenas uma vez -- `root` (Element|null, padrão `null`): root do `IntersectionObserver` -- `rootMargin` (string, padrão `"0px"`): margem do `IntersectionObserver` -- `threshold` (number|number[], padrão `0.1`): threshold do `IntersectionObserver` -- `onUpdate` (function) -- `onComplete` (function) +### Instância de grupo — múltiplos elementos -`formatter`, `onUpdate` e `onComplete` recebem: `(value, element, index)`. +Retornada quando `target` resolve para mais de um elemento. -### Instância (1 elemento) +#### Métodos -Métodos: `start()` (inicia ou retoma se pausado), `pause()`, `resume()`, `stop()`, `reset()`, `set(value)`, `update(nextEnd, nextOptions?)`, `destroy()` +Os mesmos da instância única, aplicados a todos os elementos: +`start()`, `pause()`, `resume()`, `stop()`, `reset()`, `destroy()` -Getters: `value`, `running`, `paused` +**`set(value | value[])`** — aceita um único valor (aplicado a todos) ou um array (um valor por elemento). -### Instância de grupo (vários elementos) +**`update(nextEnd | nextEnd[], nextOptions?)`** — aceita um único valor final ou um array de valores finais. -Métodos: `start()`, `pause()`, `resume()`, `stop()`, `reset()`, `set(value|array)`, `update(nextEnd|array, nextOptions?)`, `destroy()` +#### Getters -Getters: `values`, `running`, `paused`, `count` +| Getter | Tipo | Descrição | +|---|---|---| +| `.values` | `number[]` | Array com o valor atual de cada elemento. | +| `.running` | `boolean` | `true` se ao menos um elemento estiver animando. | +| `.paused` | `boolean` | `true` se ao menos um elemento estiver pausado. | +| `.waiting` | `boolean` | `true` se ao menos um elemento estiver aguardando o `sleep` disparar. | +| `.count` | `number` | Quantidade de elementos no grupo. | -## Build (somente para desenvolvimento da biblioteca) +--- + +### Exemplos de controle manual + +```js +const counter = counterUp("#score", { end: 500, autostart: false }); + +// Inicia manualmente +counter.start(); + +// Pausa e retoma +counter.pause(); +counter.resume(); + +// Muda o valor exibido sem animação +counter.set(250); + +// Muda o alvo e reinicia a animação +counter.update(1000, { duration: 800 }); + +// Lê o valor atual em qualquer momento +console.log(counter.value); + +// Libera recursos ao remover o componente +counter.destroy(); +``` + +--- + +## TypeScript + +O pacote inclui declarações nativas em `src/counterup.d.ts`. Nenhuma instalação extra é necessária. + +```ts +import { counterUp } from "@nullsablex/counter-up"; +import type { + CounterUpOptions, + CounterUpInstance, + CounterUpGroupInstance, +} from "@nullsablex/counter-up"; + +// Instância única — tipo inferido automaticamente +const counter: CounterUpInstance = counterUp("#total", { + end: 1000, + duration: 1500, + prefix: "R$ ", + decimals: 2, + onComplete: (value) => console.log("Fim:", value), +}); + +// Modo headless — target null → CounterUpInstance garantido +const headless: CounterUpInstance = counterUp(null, { + start: 0, + end: 100, + duration: 2000, + onUpdate: (value) => updateProgressBar(value), +}); + +// Opções reutilizáveis com tipagem +const opts: CounterUpOptions = { + duration: 1800, + easing: "easeOutCubic", + locale: "en-US", +}; + +counterUp(".metric", opts); +``` + +### Tipos exportados + +| Tipo | Descrição | +|---|---| +| `CounterUpOptions` | Interface completa de opções | +| `CounterUpInstance` | Instância retornada para elemento único ou headless | +| `CounterUpGroupInstance` | Instância retornada para múltiplos elementos | +| `CounterUpTarget` | União de todos os tipos aceitos como `target` | +| `EasingFunction` | `(t: number) => number` | +| `FormatterFunction` | `(value, element, index) => string` | +| `CounterUpCallback` | Assinatura de `onUpdate` e `onComplete` | + +--- + +## Build (desenvolvimento da biblioteca) ```bash npm run build @@ -138,20 +327,24 @@ npm run build Arquivos gerados em `dist/`: -- `counterup.esm.js` -- `counterup.esm.min.js` -- `counterup.umd.js` -- `counterup.umd.min.js` +- `counterup.esm.js` — ESM sem minificação +- `counterup.esm.min.js` — ESM minificado +- `counterup.umd.js` — UMD sem minificação +- `counterup.umd.min.js` — UMD minificado (indicado para uso via ` + diff --git a/dist/counterup.esm.js b/dist/counterup.esm.js index 5e45ed1..a5ac3d2 100644 --- a/dist/counterup.esm.js +++ b/dist/counterup.esm.js @@ -1,4 +1,14 @@ -/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +/* @nullsablex/counter-up v0.2.0 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +const raf = + typeof requestAnimationFrame !== "undefined" + ? (cb) => requestAnimationFrame(cb) + : (cb) => setTimeout(() => cb(Date.now()), 16); + +const caf = + typeof cancelAnimationFrame !== "undefined" + ? (id) => cancelAnimationFrame(id) + : (id) => clearTimeout(id); + const easings = { linear: (t) => t, easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), @@ -16,6 +26,7 @@ const defaultOptions = { useGrouping: true, easing: "easeOutCubic", formatter: null, + sleep: 0, autostart: true, startOnView: false, once: true, @@ -66,8 +77,35 @@ function isElement(target) { return typeof Element !== "undefined" && target instanceof Element; } +function readElementValue(element) { + const text = (element.textContent || "").trim(); + // Strip everything except digits, dot, comma and minus sign + const cleaned = text.replace(/[^\d.,-]/g, ""); + if (!cleaned) return null; + // Remove commas (treat as thousands separator) and parse + const n = parseFloat(cleaned.replace(/,/g, "")); + return Number.isFinite(n) ? n : null; +} + +function readElementDecimals(element) { + const text = (element.textContent || "").trim(); + const cleaned = text.replace(/[^\d.,-]/g, "").replace(/,/g, ""); + const dot = cleaned.indexOf("."); + return dot === -1 ? 0 : cleaned.length - dot - 1; +} + function resolveElements(target) { + if (target == null) { + return [null]; + } + if (typeof target === "string") { + if (typeof document === "undefined") { + throw new Error( + "counterUp: `document` não está disponível neste ambiente. " + + "Passe um elemento DOM diretamente ou use modo headless (target null + onUpdate)." + ); + } return Array.from(document.querySelectorAll(target)); } @@ -91,12 +129,26 @@ function resolveElements(target) { } function createCounterInstance(element, userOptions = {}, index = 0) { - if (!element) { + if (element !== null && !isElement(element)) { throw new Error("counterUp: target element not found."); } + // When end is not provided and there is a DOM element, read its current text as the target value. + // When decimals is also not provided, infer it from the element's text. + const autoEnd = + element !== null && userOptions.end === undefined + ? readElementValue(element) + : null; + + const autoDecimals = + autoEnd !== null && userOptions.decimals === undefined + ? readElementDecimals(element) + : null; + let options = normalizeOptions({ ...defaultOptions, + ...(autoEnd !== null ? { end: autoEnd } : {}), + ...(autoDecimals !== null ? { decimals: autoDecimals } : {}), ...userOptions, element, index, @@ -109,6 +161,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { elapsed: 0, startTime: null, rafId: null, + sleepId: null, isRunning: false, isPaused: false, destroyed: false, @@ -118,7 +171,9 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function render(value, notify = true) { state.value = value; - element.textContent = formatNumber(value, options); + if (element !== null) { + element.textContent = formatNumber(value, options); + } if (notify && typeof options.onUpdate === "function") { options.onUpdate(value, element, index); } @@ -126,11 +181,18 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function cancelFrame() { if (state.rafId !== null) { - cancelAnimationFrame(state.rafId); + caf(state.rafId); state.rafId = null; } } + function clearSleep() { + if (state.sleepId !== null) { + clearTimeout(state.sleepId); + state.sleepId = null; + } + } + function disconnectObserver() { if (state.observer) { state.observer.disconnect(); @@ -154,7 +216,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { render(nextValue); if (progress < 1) { - state.rafId = requestAnimationFrame(animate); + state.rafId = raf(animate); return; } @@ -170,19 +232,32 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function play(from, to) { if (state.destroyed) return api; + clearSleep(); cancelFrame(); state.from = toNumber(from, state.value); state.to = toNumber(to, state.to); state.elapsed = 0; state.startTime = null; state.isPaused = false; - state.isRunning = true; state.hasPlayed = true; - state.rafId = requestAnimationFrame(animate); + + const sleepMs = Math.max(0, toNumber(options.sleep, 0)); + if (sleepMs > 0) { + state.sleepId = setTimeout(() => { + state.sleepId = null; + state.isRunning = true; + state.rafId = raf(animate); + }, sleepMs); + } else { + state.isRunning = true; + state.rafId = raf(animate); + } + return api; } function stop() { + clearSleep(); cancelFrame(); state.isRunning = false; state.isPaused = false; @@ -206,6 +281,11 @@ function createCounterInstance(element, userOptions = {}, index = 0) { } function pause() { + if (state.sleepId !== null) { + clearSleep(); + state.isPaused = true; + return api; + } if (!state.isRunning) return api; cancelFrame(); state.isRunning = false; @@ -221,7 +301,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { state.isPaused = false; // Re-anchor startTime using the saved elapsed time. state.startTime = null; - state.rafId = requestAnimationFrame(animate); + state.rafId = raf(animate); return api; } @@ -265,6 +345,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { if ( !options.startOnView || !options.autostart || + element === null || typeof IntersectionObserver === "undefined" ) { return; @@ -321,6 +402,9 @@ function createCounterInstance(element, userOptions = {}, index = 0) { get paused() { return state.isPaused; }, + get waiting() { + return state.sleepId !== null; + }, }; render(options.start, false); @@ -394,6 +478,9 @@ function createGroupInstance(elements, userOptions) { get paused() { return instances.some((instance) => instance.paused); }, + get waiting() { + return instances.some((instance) => instance.waiting); + }, get count() { return instances.length; }, diff --git a/dist/counterup.esm.min.js b/dist/counterup.esm.min.js index af702bd..c847bf6 100644 --- a/dist/counterup.esm.min.js +++ b/dist/counterup.esm.min.js @@ -1,4 +1,8 @@ -/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ -const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,autostart:true,startOnView:false,once:true,root:null,rootMargin:"0px",threshold:0.1,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function resolveElements(target){if(typeof target==="string"){return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(!element){throw new Error("counterUp: target element not found.");}let options=normalizeOptions({...defaultOptions,...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,isRunning:false,isPaused:false,destroyed:false,hasPlayed:false,observer:null,};function render(value,notify=true){state.value=value;element.textContent=formatNumber(value,options);if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){cancelAnimationFrame(state.rafId);state.rafId=null;}}function disconnectObserver(){if(state.observer){state.observer.disconnect();state.observer=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=requestAnimationFrame(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.isRunning=true;state.hasPlayed=true;state.rafId=requestAnimationFrame(animate);return api;}function stop(){cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.destroyed)return api;if(options.startOnView&&options.once){disconnectObserver();}if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; +/* @nullsablex/counter-up v0.2.0 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +const raf=typeof requestAnimationFrame!=="undefined"?(cb)=>requestAnimationFrame(cb):(cb)=>setTimeout(()=>cb(Date.now()),16);const caf=typeof cancelAnimationFrame!=="undefined"?(id)=>cancelAnimationFrame(id):(id)=>clearTimeout(id);const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,sleep:0,autostart:true,startOnView:false,once:true,root:null,rootMargin:"0px",threshold:0.1,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function readElementValue(element){const text=(element.textContent||"").trim(); +const cleaned=text.replace(/[^\d.,-]/g,"");if(!cleaned)return null; +const n=parseFloat(cleaned.replace(/,/g,""));return Number.isFinite(n)?n:null;}function readElementDecimals(element){const text=(element.textContent||"").trim();const cleaned=text.replace(/[^\d.,-]/g,"").replace(/,/g,"");const dot=cleaned.indexOf(".");return dot===-1?0:cleaned.length-dot-1;}function resolveElements(target){if(target==null){return[null];}if(typeof target==="string"){if(typeof document==="undefined"){throw new Error("counterUp: `document` não está disponível neste ambiente. "+"Passe um elemento DOM diretamente ou use modo headless (target null + onUpdate).");}return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(element!==null&&!isElement(element)){throw new Error("counterUp: target element not found.");} + +const autoEnd=element!==null&&userOptions.end===undefined?readElementValue(element):null;const autoDecimals=autoEnd!==null&&userOptions.decimals===undefined?readElementDecimals(element):null;let options=normalizeOptions({...defaultOptions,...(autoEnd!==null?{end:autoEnd}:{}),...(autoDecimals!==null?{decimals:autoDecimals}:{}),...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,sleepId:null,isRunning:false,isPaused:false,destroyed:false,hasPlayed:false,observer:null,};function render(value,notify=true){state.value=value;if(element!==null){element.textContent=formatNumber(value,options);}if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){caf(state.rafId);state.rafId=null;}}function clearSleep(){if(state.sleepId!==null){clearTimeout(state.sleepId);state.sleepId=null;}}function disconnectObserver(){if(state.observer){state.observer.disconnect();state.observer=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=raf(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;clearSleep();cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.hasPlayed=true;const sleepMs=Math.max(0,toNumber(options.sleep,0));if(sleepMs>0){state.sleepId=setTimeout(()=>{state.sleepId=null;state.isRunning=true;state.rafId=raf(animate);},sleepMs);}else{state.isRunning=true;state.rafId=raf(animate);}return api;}function stop(){clearSleep();cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.destroyed)return api;if(options.startOnView&&options.once){disconnectObserver();}if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(state.sleepId!==null){clearSleep();state.isPaused=true;return api;}if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; state.startTime=null;return api;}function resume(){if(!state.isPaused||state.destroyed)return api;state.isRunning=true;state.isPaused=false; -state.startTime=null;state.rafId=requestAnimationFrame(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;disconnectObserver();options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();disconnectObserver();state.destroyed=true;}function setupObserver(){if(!options.startOnView||!options.autostart||typeof IntersectionObserver==="undefined"){return;}disconnectObserver();state.observer=new IntersectionObserver((entries)=>{const entry=entries[0];if(!entry)return;if(entry.isIntersecting){if(options.once&&state.hasPlayed){return;}render(options.start,false);play(options.start,options.end);if(options.once){disconnectObserver();}return;}if(!options.once){stop();render(options.start,false);}},{root:options.root,rootMargin:options.rootMargin,threshold:options.threshold,});state.observer.observe(element);}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},};render(options.start,false);if(options.startOnView){setupObserver();}else if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get count(){return instances.length;},};return api;}export function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}export default counterUp; \ No newline at end of file +state.startTime=null;state.rafId=raf(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;disconnectObserver();options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();disconnectObserver();state.destroyed=true;}function setupObserver(){if(!options.startOnView||!options.autostart||element===null||typeof IntersectionObserver==="undefined"){return;}disconnectObserver();state.observer=new IntersectionObserver((entries)=>{const entry=entries[0];if(!entry)return;if(entry.isIntersecting){if(options.once&&state.hasPlayed){return;}render(options.start,false);play(options.start,options.end);if(options.once){disconnectObserver();}return;}if(!options.once){stop();render(options.start,false);}},{root:options.root,rootMargin:options.rootMargin,threshold:options.threshold,});state.observer.observe(element);}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},get waiting(){return state.sleepId!==null;},};render(options.start,false);if(options.startOnView){setupObserver();}else if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get waiting(){return instances.some((instance)=>instance.waiting);},get count(){return instances.length;},};return api;}export function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}export default counterUp; \ No newline at end of file diff --git a/dist/counterup.umd.js b/dist/counterup.umd.js index bf2db7d..212276f 100644 --- a/dist/counterup.umd.js +++ b/dist/counterup.umd.js @@ -1,12 +1,22 @@ -/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +/* @nullsablex/counter-up v0.2.0 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ (function (global, factory) { if (typeof module === "object" && typeof module.exports === "object") { module.exports = factory(); } else { - global.CounterUp = factory(); + global.counterUp = factory(); } -})(typeof window !== "undefined" ? window : this, function () { +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this, function () { "use strict"; + const raf = + typeof requestAnimationFrame !== "undefined" + ? (cb) => requestAnimationFrame(cb) + : (cb) => setTimeout(() => cb(Date.now()), 16); + + const caf = + typeof cancelAnimationFrame !== "undefined" + ? (id) => cancelAnimationFrame(id) + : (id) => clearTimeout(id); + const easings = { linear: (t) => t, easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), @@ -24,6 +34,7 @@ useGrouping: true, easing: "easeOutCubic", formatter: null, + sleep: 0, autostart: true, startOnView: false, once: true, @@ -74,8 +85,35 @@ return typeof Element !== "undefined" && target instanceof Element; } + function readElementValue(element) { + const text = (element.textContent || "").trim(); + // Strip everything except digits, dot, comma and minus sign + const cleaned = text.replace(/[^\d.,-]/g, ""); + if (!cleaned) return null; + // Remove commas (treat as thousands separator) and parse + const n = parseFloat(cleaned.replace(/,/g, "")); + return Number.isFinite(n) ? n : null; + } + + function readElementDecimals(element) { + const text = (element.textContent || "").trim(); + const cleaned = text.replace(/[^\d.,-]/g, "").replace(/,/g, ""); + const dot = cleaned.indexOf("."); + return dot === -1 ? 0 : cleaned.length - dot - 1; + } + function resolveElements(target) { + if (target == null) { + return [null]; + } + if (typeof target === "string") { + if (typeof document === "undefined") { + throw new Error( + "counterUp: `document` não está disponível neste ambiente. " + + "Passe um elemento DOM diretamente ou use modo headless (target null + onUpdate)." + ); + } return Array.from(document.querySelectorAll(target)); } @@ -99,12 +137,26 @@ } function createCounterInstance(element, userOptions = {}, index = 0) { - if (!element) { + if (element !== null && !isElement(element)) { throw new Error("counterUp: target element not found."); } + // When end is not provided and there is a DOM element, read its current text as the target value. + // When decimals is also not provided, infer it from the element's text. + const autoEnd = + element !== null && userOptions.end === undefined + ? readElementValue(element) + : null; + + const autoDecimals = + autoEnd !== null && userOptions.decimals === undefined + ? readElementDecimals(element) + : null; + let options = normalizeOptions({ ...defaultOptions, + ...(autoEnd !== null ? { end: autoEnd } : {}), + ...(autoDecimals !== null ? { decimals: autoDecimals } : {}), ...userOptions, element, index, @@ -117,6 +169,7 @@ elapsed: 0, startTime: null, rafId: null, + sleepId: null, isRunning: false, isPaused: false, destroyed: false, @@ -126,7 +179,9 @@ function render(value, notify = true) { state.value = value; - element.textContent = formatNumber(value, options); + if (element !== null) { + element.textContent = formatNumber(value, options); + } if (notify && typeof options.onUpdate === "function") { options.onUpdate(value, element, index); } @@ -134,11 +189,18 @@ function cancelFrame() { if (state.rafId !== null) { - cancelAnimationFrame(state.rafId); + caf(state.rafId); state.rafId = null; } } + function clearSleep() { + if (state.sleepId !== null) { + clearTimeout(state.sleepId); + state.sleepId = null; + } + } + function disconnectObserver() { if (state.observer) { state.observer.disconnect(); @@ -162,7 +224,7 @@ render(nextValue); if (progress < 1) { - state.rafId = requestAnimationFrame(animate); + state.rafId = raf(animate); return; } @@ -178,19 +240,32 @@ function play(from, to) { if (state.destroyed) return api; + clearSleep(); cancelFrame(); state.from = toNumber(from, state.value); state.to = toNumber(to, state.to); state.elapsed = 0; state.startTime = null; state.isPaused = false; - state.isRunning = true; state.hasPlayed = true; - state.rafId = requestAnimationFrame(animate); + + const sleepMs = Math.max(0, toNumber(options.sleep, 0)); + if (sleepMs > 0) { + state.sleepId = setTimeout(() => { + state.sleepId = null; + state.isRunning = true; + state.rafId = raf(animate); + }, sleepMs); + } else { + state.isRunning = true; + state.rafId = raf(animate); + } + return api; } function stop() { + clearSleep(); cancelFrame(); state.isRunning = false; state.isPaused = false; @@ -214,6 +289,11 @@ } function pause() { + if (state.sleepId !== null) { + clearSleep(); + state.isPaused = true; + return api; + } if (!state.isRunning) return api; cancelFrame(); state.isRunning = false; @@ -229,7 +309,7 @@ state.isPaused = false; // Re-anchor startTime using the saved elapsed time. state.startTime = null; - state.rafId = requestAnimationFrame(animate); + state.rafId = raf(animate); return api; } @@ -273,6 +353,7 @@ if ( !options.startOnView || !options.autostart || + element === null || typeof IntersectionObserver === "undefined" ) { return; @@ -329,6 +410,9 @@ get paused() { return state.isPaused; }, + get waiting() { + return state.sleepId !== null; + }, }; render(options.start, false); @@ -402,6 +486,9 @@ get paused() { return instances.some((instance) => instance.paused); }, + get waiting() { + return instances.some((instance) => instance.waiting); + }, get count() { return instances.length; }, @@ -425,5 +512,5 @@ } - return { counterUp: counterUp, default: counterUp }; + return counterUp; }); diff --git a/dist/counterup.umd.min.js b/dist/counterup.umd.min.js index a280cc7..5ea0e4c 100644 --- a/dist/counterup.umd.min.js +++ b/dist/counterup.umd.min.js @@ -1,4 +1,8 @@ -/* @nullsablex/counter-up v0.1.7 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ -(function(global,factory){if(typeof module==="object"&&typeof module.exports==="object"){module.exports=factory();}else{global.CounterUp=factory();}})(typeof window!=="undefined"?window:this,function(){"use strict";const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,autostart:true,startOnView:false,once:true,root:null,rootMargin:"0px",threshold:0.1,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function resolveElements(target){if(typeof target==="string"){return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(!element){throw new Error("counterUp: target element not found.");}let options=normalizeOptions({...defaultOptions,...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,isRunning:false,isPaused:false,destroyed:false,hasPlayed:false,observer:null,};function render(value,notify=true){state.value=value;element.textContent=formatNumber(value,options);if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){cancelAnimationFrame(state.rafId);state.rafId=null;}}function disconnectObserver(){if(state.observer){state.observer.disconnect();state.observer=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=requestAnimationFrame(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.isRunning=true;state.hasPlayed=true;state.rafId=requestAnimationFrame(animate);return api;}function stop(){cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.destroyed)return api;if(options.startOnView&&options.once){disconnectObserver();}if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; +/* @nullsablex/counter-up v0.2.0 | Author: NullSablex | git+https://github.com/NullSablex/counter-up.git | MIT License */ +(function(global,factory){if(typeof module==="object"&&typeof module.exports==="object"){module.exports=factory();}else{global.counterUp=factory();}})(typeof globalThis!=="undefined"?globalThis:typeof window!=="undefined"?window:this,function(){"use strict";const raf=typeof requestAnimationFrame!=="undefined"?(cb)=>requestAnimationFrame(cb):(cb)=>setTimeout(()=>cb(Date.now()),16);const caf=typeof cancelAnimationFrame!=="undefined"?(id)=>cancelAnimationFrame(id):(id)=>clearTimeout(id);const easings={linear:(t)=>t,easeInOutQuad:(t)=>(t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2),easeOutCubic:(t)=>1-Math.pow(1-t,3),};const defaultOptions={start:0,end:100,duration:2000,decimals:0,prefix:"",suffix:"",locale:"pt-BR",useGrouping:true,easing:"easeOutCubic",formatter:null,sleep:0,autostart:true,startOnView:false,once:true,root:null,rootMargin:"0px",threshold:0.1,onUpdate:null,onComplete:null,};function toNumber(value,fallback=0){const n=Number(value);return Number.isFinite(n)?n:fallback;}function normalizeOptions(options){const normalized={...options,start:toNumber(options.start,0),end:toNumber(options.end,100),duration:Math.max(0,toNumber(options.duration,2000)),decimals:Math.max(0,Math.floor(toNumber(options.decimals,0))),};normalized.easingFn=typeof normalized.easing==="function"?normalized.easing:easings[normalized.easing]||easings.easeOutCubic;return normalized;}function formatNumber(value,options){if(typeof options.formatter==="function"){return options.formatter(value,options.element,options.index);}const formatted=new Intl.NumberFormat(options.locale,{minimumFractionDigits:options.decimals,maximumFractionDigits:options.decimals,useGrouping:options.useGrouping,}).format(value);return`${options.prefix}${formatted}${options.suffix}`;}function isElement(target){return typeof Element!=="undefined"&&target instanceof Element;}function readElementValue(element){const text=(element.textContent||"").trim(); +const cleaned=text.replace(/[^\d.,-]/g,"");if(!cleaned)return null; +const n=parseFloat(cleaned.replace(/,/g,""));return Number.isFinite(n)?n:null;}function readElementDecimals(element){const text=(element.textContent||"").trim();const cleaned=text.replace(/[^\d.,-]/g,"").replace(/,/g,"");const dot=cleaned.indexOf(".");return dot===-1?0:cleaned.length-dot-1;}function resolveElements(target){if(target==null){return[null];}if(typeof target==="string"){if(typeof document==="undefined"){throw new Error("counterUp: `document` não está disponível neste ambiente. "+"Passe um elemento DOM diretamente ou use modo headless (target null + onUpdate).");}return Array.from(document.querySelectorAll(target));}if(isElement(target)){return[target];}if(Array.isArray(target)){return target.filter(isElement);}if(typeof NodeList!=="undefined"&&(target instanceof NodeList||(typeof HTMLCollection!=="undefined"&&target instanceof HTMLCollection))){return Array.from(target).filter(isElement);}return[];}function createCounterInstance(element,userOptions={},index=0){if(element!==null&&!isElement(element)){throw new Error("counterUp: target element not found.");} + +const autoEnd=element!==null&&userOptions.end===undefined?readElementValue(element):null;const autoDecimals=autoEnd!==null&&userOptions.decimals===undefined?readElementDecimals(element):null;let options=normalizeOptions({...defaultOptions,...(autoEnd!==null?{end:autoEnd}:{}),...(autoDecimals!==null?{decimals:autoDecimals}:{}),...userOptions,element,index,});const state={value:options.start,from:options.start,to:options.end,elapsed:0,startTime:null,rafId:null,sleepId:null,isRunning:false,isPaused:false,destroyed:false,hasPlayed:false,observer:null,};function render(value,notify=true){state.value=value;if(element!==null){element.textContent=formatNumber(value,options);}if(notify&&typeof options.onUpdate==="function"){options.onUpdate(value,element,index);}}function cancelFrame(){if(state.rafId!==null){caf(state.rafId);state.rafId=null;}}function clearSleep(){if(state.sleepId!==null){clearTimeout(state.sleepId);state.sleepId=null;}}function disconnectObserver(){if(state.observer){state.observer.disconnect();state.observer=null;}}function animate(timestamp){if(!state.isRunning||state.destroyed)return;if(state.startTime===null){state.startTime=timestamp-state.elapsed;}state.elapsed=timestamp-state.startTime;const progress=options.duration===0?1:Math.min(state.elapsed/options.duration,1);const eased=options.easingFn(progress);const nextValue=state.from+(state.to-state.from)*eased;render(nextValue);if(progress<1){state.rafId=raf(animate);return;}state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;render(state.to);if(typeof options.onComplete==="function"){options.onComplete(state.to,element,index);}}function play(from,to){if(state.destroyed)return api;clearSleep();cancelFrame();state.from=toNumber(from,state.value);state.to=toNumber(to,state.to);state.elapsed=0;state.startTime=null;state.isPaused=false;state.hasPlayed=true;const sleepMs=Math.max(0,toNumber(options.sleep,0));if(sleepMs>0){state.sleepId=setTimeout(()=>{state.sleepId=null;state.isRunning=true;state.rafId=raf(animate);},sleepMs);}else{state.isRunning=true;state.rafId=raf(animate);}return api;}function stop(){clearSleep();cancelFrame();state.isRunning=false;state.isPaused=false;state.elapsed=0;state.startTime=null;return api;}function start(){if(state.destroyed)return api;if(options.startOnView&&options.once){disconnectObserver();}if(state.isPaused){return resume();}if(state.isRunning){return api;}return play(options.start,options.end);}function pause(){if(state.sleepId!==null){clearSleep();state.isPaused=true;return api;}if(!state.isRunning)return api;cancelFrame();state.isRunning=false;state.isPaused=true; state.startTime=null;return api;}function resume(){if(!state.isPaused||state.destroyed)return api;state.isRunning=true;state.isPaused=false; -state.startTime=null;state.rafId=requestAnimationFrame(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;disconnectObserver();options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();disconnectObserver();state.destroyed=true;}function setupObserver(){if(!options.startOnView||!options.autostart||typeof IntersectionObserver==="undefined"){return;}disconnectObserver();state.observer=new IntersectionObserver((entries)=>{const entry=entries[0];if(!entry)return;if(entry.isIntersecting){if(options.once&&state.hasPlayed){return;}render(options.start,false);play(options.start,options.end);if(options.once){disconnectObserver();}return;}if(!options.once){stop();render(options.start,false);}},{root:options.root,rootMargin:options.rootMargin,threshold:options.threshold,});state.observer.observe(element);}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},};render(options.start,false);if(options.startOnView){setupObserver();}else if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get count(){return instances.length;},};return api;}function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}return{counterUp:counterUp,default:counterUp};}); \ No newline at end of file +state.startTime=null;state.rafId=raf(animate);return api;}function reset(){stop();render(options.start,false);return api;}function set(value){const nextValue=toNumber(value,state.value);stop();state.from=nextValue;state.to=nextValue;render(nextValue);return api;}function update(nextEnd,nextOptions={}){if(state.destroyed)return api;disconnectObserver();options=normalizeOptions({...options,...nextOptions,element,index,start:nextOptions.start===undefined?state.value:toNumber(nextOptions.start),end:toNumber(nextEnd,options.end),});return play(options.start,options.end);}function destroy(){stop();disconnectObserver();state.destroyed=true;}function setupObserver(){if(!options.startOnView||!options.autostart||element===null||typeof IntersectionObserver==="undefined"){return;}disconnectObserver();state.observer=new IntersectionObserver((entries)=>{const entry=entries[0];if(!entry)return;if(entry.isIntersecting){if(options.once&&state.hasPlayed){return;}render(options.start,false);play(options.start,options.end);if(options.once){disconnectObserver();}return;}if(!options.once){stop();render(options.start,false);}},{root:options.root,rootMargin:options.rootMargin,threshold:options.threshold,});state.observer.observe(element);}const api={start,stop,pause,resume,reset,set,update,destroy,get value(){return state.value;},get running(){return state.isRunning;},get paused(){return state.isPaused;},get waiting(){return state.sleepId!==null;},};render(options.start,false);if(options.startOnView){setupObserver();}else if(options.autostart){start();}return api;}function createGroupInstance(elements,userOptions){const instances=elements.map((element,index)=>createCounterInstance(element,userOptions,index));const api={start(){instances.forEach((instance)=>instance.start());return api;},stop(){instances.forEach((instance)=>instance.stop());return api;},pause(){instances.forEach((instance)=>instance.pause());return api;},resume(){instances.forEach((instance)=>instance.resume());return api;},reset(){instances.forEach((instance)=>instance.reset());return api;},set(value){if(Array.isArray(value)){instances.forEach((instance,index)=>{instance.set(value[index]??value[value.length-1]??0);});return api;}instances.forEach((instance)=>instance.set(value));return api;},update(nextEnd,nextOptions={}){if(Array.isArray(nextEnd)){instances.forEach((instance,index)=>{instance.update(nextEnd[index]??nextEnd[nextEnd.length-1]??0,nextOptions);});return api;}instances.forEach((instance)=>instance.update(nextEnd,nextOptions));return api;},destroy(){instances.forEach((instance)=>instance.destroy());return api;},get values(){return instances.map((instance)=>instance.value);},get running(){return instances.some((instance)=>instance.running);},get paused(){return instances.some((instance)=>instance.paused);},get waiting(){return instances.some((instance)=>instance.waiting);},get count(){return instances.length;},};return api;}function counterUp(target,userOptions={}){const elements=resolveElements(target);if(elements.length===0){throw new Error("counterUp: target element not found.");}if(elements.length===1){return createCounterInstance(elements[0],userOptions,0);}return createGroupInstance(elements,userOptions);}return counterUp;}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1c6f775..7ea1dbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nullsablex/counter-up", - "version": "0.1.7", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nullsablex/counter-up", - "version": "0.1.7", + "version": "0.2.0", "license": "MIT" } } diff --git a/package.json b/package.json index ca20214..eb2dd04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nullsablex/counter-up", - "version": "0.1.7", + "version": "0.2.0", "description": "Biblioteca JS pura para animação de contadores numéricos", "author": "NullSablex", "repository": { @@ -20,9 +20,11 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, + "types": "./src/counterup.d.ts", "module": "./dist/counterup.esm.js", "exports": { ".": { + "types": "./src/counterup.d.ts", "import": "./dist/counterup.esm.js", "default": "./dist/counterup.umd.js" } @@ -44,7 +46,10 @@ "counter", "counterup", "animation", - "vanilla-js" + "vanilla-js", + "typescript", + "ssr", + "headless" ], "license": "MIT" } diff --git a/scripts/build.mjs b/scripts/build.mjs index c7f7e9b..81db3ff 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -24,12 +24,12 @@ function toUmdSource(esmSource) { if (typeof module === "object" && typeof module.exports === "object") { module.exports = factory(); } else { - global.CounterUp = factory(); + global.counterUp = factory(); } -})(typeof window !== "undefined" ? window : this, function () { +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this, function () { "use strict"; ${indent(body, 2)} - return { counterUp: counterUp, default: counterUp }; + return counterUp; }); `; } diff --git a/src/counterup.d.ts b/src/counterup.d.ts new file mode 100644 index 0000000..6b9d391 --- /dev/null +++ b/src/counterup.d.ts @@ -0,0 +1,326 @@ +/** + * @nullsablex/counter-up — TypeScript declarations + */ + +// ── Primitivos ──────────────────────────────────────────────────────────────── + +/** Função de easing personalizada. Recebe `t` (0–1) e retorna o valor eased (0–1). */ +export type EasingFunction = (t: number) => number; + +/** Nomes dos easings embutidos. */ +export type EasingName = "linear" | "easeInOutQuad" | "easeOutCubic"; + +/** + * Função de formatação personalizada. + * Substitui toda a lógica padrão de formatação. + * @param value Valor numérico atual (sem formatação). + * @param element Elemento DOM associado, ou `null` em modo headless. + * @param index Posição do elemento no grupo (0-based). `0` para instância única. + */ +export type FormatterFunction = ( + value: number, + element: Element | null, + index: number, +) => string; + +/** + * Função de callback chamada a cada frame (onUpdate) ou ao término (onComplete). + * @param value Valor numérico atual. + * @param element Elemento DOM associado, ou `null` em modo headless. + * @param index Posição do elemento no grupo (0-based). + */ +export type CounterUpCallback = ( + value: number, + element: Element | null, + index: number, +) => void; + +// ── Opções ──────────────────────────────────────────────────────────────────── + +export interface CounterUpOptions { + /** + * Valor inicial da animação. O contador começa exibindo este número. + * @default 0 + */ + start?: number; + + /** + * Valor final da animação. + * Quando omitido e um elemento DOM é fornecido, a biblioteca lê o `textContent` + * do elemento e usa esse valor como destino — ideal para conteúdo SSR/PHP. + * Obrigatório em modo headless (`target = null`). + * @default auto (lido do textContent do elemento) + */ + end?: number; + + /** + * Duração total da animação em milissegundos. + * `0` pula diretamente para o valor final sem animação. + * @default 2000 + */ + duration?: number; + + /** + * Casas decimais exibidas. + * Quando omitido junto com `end`, é inferido automaticamente do `textContent` + * do elemento (ex.: `"15.50"` → `2`). + * @default auto (inferido do textContent) + */ + decimals?: number; + + /** + * Texto adicionado **antes** do número formatado. + * @example "R$ " | "$" | "€ " + * @default "" + */ + prefix?: string; + + /** + * Texto adicionado **depois** do número formatado. + * @example "%" | " pts" | " km" + * @default "" + */ + suffix?: string; + + /** + * Locale para `Intl.NumberFormat`. Controla separadores decimal e de milhar. + * @example "pt-BR" | "en-US" | "de-DE" | "fr-FR" + * @default "pt-BR" + */ + locale?: string; + + /** + * Exibe separador de milhar conforme o locale (`1.000` vs `1000`). + * @default true + */ + useGrouping?: boolean; + + /** + * Curva de aceleração da animação. + * Aceita um nome de preset ou uma função `(t: number) => number` onde `t` vai de 0 a 1. + * @default "easeOutCubic" + */ + easing?: EasingName | EasingFunction; + + /** + * Função de formatação personalizada. Substitui toda a lógica padrão. + * Recebe `(value, element, index)` e deve retornar uma string. + * @default null + */ + formatter?: FormatterFunction | null; + + /** + * Tempo de espera em milissegundos antes de a animação começar. + * `0` inicia imediatamente. Cancelado por `.stop()`, `.pause()` ou `.destroy()`. + * @default 0 + */ + sleep?: number; + + /** + * Inicia a animação automaticamente ao criar a instância. + * Se `false`, aguarda uma chamada manual a `.start()`. + * @default true + */ + autostart?: boolean; + + /** + * Usa `IntersectionObserver` para iniciar a animação somente quando o elemento + * entra na viewport. Ignorado em modo headless (sem DOM). + * @default false + */ + startOnView?: boolean; + + /** + * Usado com `startOnView`: se `true`, a animação dispara apenas na primeira vez. + * Se `false`, reinicia toda vez que o elemento entrar na viewport. + * @default true + */ + once?: boolean; + + /** + * Elemento raiz do `IntersectionObserver`. `null` usa o viewport da janela. + * @default null + */ + root?: Element | null; + + /** + * Margem ao redor do root, no formato CSS. + * @example "0px" | "0px 0px -100px 0px" + * @default "0px" + */ + rootMargin?: string; + + /** + * Fração do elemento que precisa estar visível para disparar. + * `0.1` = 10%, `1` = 100% visível. + * @default 0.1 + */ + threshold?: number | number[]; + + /** + * Chamado a cada frame da animação com `(value, element, index)`. + * `element` é `null` em modo headless. + * @default null + */ + onUpdate?: CounterUpCallback | null; + + /** + * Chamado uma vez quando a animação termina com `(value, element, index)`. + * `element` é `null` em modo headless. + * @default null + */ + onComplete?: CounterUpCallback | null; +} + +// ── Instância única ─────────────────────────────────────────────────────────── + +/** Instância retornada para um único elemento DOM ou em modo headless (`null`). */ +export interface CounterUpInstance { + /** + * Inicia a animação. + * Se estiver pausada, retoma do ponto onde parou. + * Se já estiver rodando, não faz nada. + */ + start(): CounterUpInstance; + + /** Para a animação e reseta o progresso interno (não reseta o valor exibido). */ + stop(): CounterUpInstance; + + /** Pausa a animação preservando o progresso atual. */ + pause(): CounterUpInstance; + + /** Retoma a animação do ponto em que foi pausada. */ + resume(): CounterUpInstance; + + /** Para a animação e volta o valor exibido para `start`. */ + reset(): CounterUpInstance; + + /** + * Define o valor exibido instantaneamente, sem animação. + * Para qualquer animação em curso. + */ + set(value: number): CounterUpInstance; + + /** + * Muda o valor final (e opcionalmente outras opções) e reinicia a animação + * do valor atual. + */ + update(nextEnd: number, nextOptions?: Partial): CounterUpInstance; + + /** + * Para a animação, desconecta o observer e marca a instância como destruída. + * Chamadas subsequentes a qualquer método são ignoradas. + */ + destroy(): void; + + /** Valor numérico atual (sem formatação). */ + readonly value: number; + + /** `true` se a animação estiver em execução. */ + readonly running: boolean; + + /** `true` se a animação estiver pausada. */ + readonly paused: boolean; + + /** `true` se a animação estiver aguardando o `sleep` disparar. */ + readonly waiting: boolean; +} + +// ── Instância de grupo ──────────────────────────────────────────────────────── + +/** Instância retornada quando múltiplos elementos são alvo da animação. */ +export interface CounterUpGroupInstance { + /** Chama `.start()` em todos os elementos. */ + start(): CounterUpGroupInstance; + + /** Chama `.stop()` em todos os elementos. */ + stop(): CounterUpGroupInstance; + + /** Chama `.pause()` em todos os elementos. */ + pause(): CounterUpGroupInstance; + + /** Chama `.resume()` em todos os elementos. */ + resume(): CounterUpGroupInstance; + + /** Chama `.reset()` em todos os elementos. */ + reset(): CounterUpGroupInstance; + + /** Chama `.destroy()` em todos os elementos. */ + destroy(): CounterUpGroupInstance; + + /** + * Define um valor em cada elemento. + * - Valor único: aplicado a todos. + * - Array: cada elemento recebe o valor correspondente. + */ + set(value: number | number[]): CounterUpGroupInstance; + + /** + * Muda o valor final em cada elemento. + * - Valor único: aplicado a todos. + * - Array: cada elemento recebe o valor correspondente. + * Se o array for menor que o grupo, o último valor é repetido. + */ + update( + nextEnd: number | number[], + nextOptions?: Partial, + ): CounterUpGroupInstance; + + /** Array com o valor atual de cada elemento. */ + readonly values: number[]; + + /** `true` se ao menos um elemento estiver animando. */ + readonly running: boolean; + + /** `true` se ao menos um elemento estiver pausado. */ + readonly paused: boolean; + + /** `true` se ao menos um elemento estiver aguardando o `sleep` disparar. */ + readonly waiting: boolean; + + /** Quantidade de elementos no grupo. */ + readonly count: number; +} + +// ── Target ──────────────────────────────────────────────────────────────────── + +/** Tipos aceitos como `target` pela função `counterUp`. */ +export type CounterUpTarget = + | string + | Element + | NodeList + | HTMLCollection + | Element[] + | null + | undefined; + +// ── Função principal ────────────────────────────────────────────────────────── + +/** + * Modo headless — sem elemento DOM. + * Os valores são entregues exclusivamente pelo callback `onUpdate`. + * Funciona em Node.js, Next.js, Nuxt, Vitest sem jsdom. + */ +export function counterUp( + target: null | undefined, + options?: CounterUpOptions, +): CounterUpInstance; + +/** + * Elemento DOM único — retorna uma instância única. + */ +export function counterUp( + target: Element, + options?: CounterUpOptions, +): CounterUpInstance; + +/** + * Seletor CSS, NodeList, HTMLCollection ou array de elementos. + * Retorna instância única se um elemento for encontrado, instância de grupo se houver mais de um. + */ +export function counterUp( + target: string | NodeList | HTMLCollection | Element[], + options?: CounterUpOptions, +): CounterUpInstance | CounterUpGroupInstance; + +export default counterUp; diff --git a/src/counterup.js b/src/counterup.js index 5959e02..d177eee 100644 --- a/src/counterup.js +++ b/src/counterup.js @@ -1,3 +1,13 @@ +const raf = + typeof requestAnimationFrame !== "undefined" + ? (cb) => requestAnimationFrame(cb) + : (cb) => setTimeout(() => cb(Date.now()), 16); + +const caf = + typeof cancelAnimationFrame !== "undefined" + ? (id) => cancelAnimationFrame(id) + : (id) => clearTimeout(id); + const easings = { linear: (t) => t, easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), @@ -15,6 +25,7 @@ const defaultOptions = { useGrouping: true, easing: "easeOutCubic", formatter: null, + sleep: 0, autostart: true, startOnView: false, once: true, @@ -65,8 +76,35 @@ function isElement(target) { return typeof Element !== "undefined" && target instanceof Element; } +function readElementValue(element) { + const text = (element.textContent || "").trim(); + // Strip everything except digits, dot, comma and minus sign + const cleaned = text.replace(/[^\d.,-]/g, ""); + if (!cleaned) return null; + // Remove commas (treat as thousands separator) and parse + const n = parseFloat(cleaned.replace(/,/g, "")); + return Number.isFinite(n) ? n : null; +} + +function readElementDecimals(element) { + const text = (element.textContent || "").trim(); + const cleaned = text.replace(/[^\d.,-]/g, "").replace(/,/g, ""); + const dot = cleaned.indexOf("."); + return dot === -1 ? 0 : cleaned.length - dot - 1; +} + function resolveElements(target) { + if (target == null) { + return [null]; + } + if (typeof target === "string") { + if (typeof document === "undefined") { + throw new Error( + "counterUp: `document` não está disponível neste ambiente. " + + "Passe um elemento DOM diretamente ou use modo headless (target null + onUpdate)." + ); + } return Array.from(document.querySelectorAll(target)); } @@ -90,12 +128,26 @@ function resolveElements(target) { } function createCounterInstance(element, userOptions = {}, index = 0) { - if (!element) { + if (element !== null && !isElement(element)) { throw new Error("counterUp: target element not found."); } + // When end is not provided and there is a DOM element, read its current text as the target value. + // When decimals is also not provided, infer it from the element's text. + const autoEnd = + element !== null && userOptions.end === undefined + ? readElementValue(element) + : null; + + const autoDecimals = + autoEnd !== null && userOptions.decimals === undefined + ? readElementDecimals(element) + : null; + let options = normalizeOptions({ ...defaultOptions, + ...(autoEnd !== null ? { end: autoEnd } : {}), + ...(autoDecimals !== null ? { decimals: autoDecimals } : {}), ...userOptions, element, index, @@ -108,6 +160,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { elapsed: 0, startTime: null, rafId: null, + sleepId: null, isRunning: false, isPaused: false, destroyed: false, @@ -117,7 +170,9 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function render(value, notify = true) { state.value = value; - element.textContent = formatNumber(value, options); + if (element !== null) { + element.textContent = formatNumber(value, options); + } if (notify && typeof options.onUpdate === "function") { options.onUpdate(value, element, index); } @@ -125,11 +180,18 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function cancelFrame() { if (state.rafId !== null) { - cancelAnimationFrame(state.rafId); + caf(state.rafId); state.rafId = null; } } + function clearSleep() { + if (state.sleepId !== null) { + clearTimeout(state.sleepId); + state.sleepId = null; + } + } + function disconnectObserver() { if (state.observer) { state.observer.disconnect(); @@ -153,7 +215,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { render(nextValue); if (progress < 1) { - state.rafId = requestAnimationFrame(animate); + state.rafId = raf(animate); return; } @@ -169,19 +231,32 @@ function createCounterInstance(element, userOptions = {}, index = 0) { function play(from, to) { if (state.destroyed) return api; + clearSleep(); cancelFrame(); state.from = toNumber(from, state.value); state.to = toNumber(to, state.to); state.elapsed = 0; state.startTime = null; state.isPaused = false; - state.isRunning = true; state.hasPlayed = true; - state.rafId = requestAnimationFrame(animate); + + const sleepMs = Math.max(0, toNumber(options.sleep, 0)); + if (sleepMs > 0) { + state.sleepId = setTimeout(() => { + state.sleepId = null; + state.isRunning = true; + state.rafId = raf(animate); + }, sleepMs); + } else { + state.isRunning = true; + state.rafId = raf(animate); + } + return api; } function stop() { + clearSleep(); cancelFrame(); state.isRunning = false; state.isPaused = false; @@ -205,6 +280,11 @@ function createCounterInstance(element, userOptions = {}, index = 0) { } function pause() { + if (state.sleepId !== null) { + clearSleep(); + state.isPaused = true; + return api; + } if (!state.isRunning) return api; cancelFrame(); state.isRunning = false; @@ -220,7 +300,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { state.isPaused = false; // Re-anchor startTime using the saved elapsed time. state.startTime = null; - state.rafId = requestAnimationFrame(animate); + state.rafId = raf(animate); return api; } @@ -264,6 +344,7 @@ function createCounterInstance(element, userOptions = {}, index = 0) { if ( !options.startOnView || !options.autostart || + element === null || typeof IntersectionObserver === "undefined" ) { return; @@ -320,6 +401,9 @@ function createCounterInstance(element, userOptions = {}, index = 0) { get paused() { return state.isPaused; }, + get waiting() { + return state.sleepId !== null; + }, }; render(options.start, false); @@ -393,6 +477,9 @@ function createGroupInstance(elements, userOptions) { get paused() { return instances.some((instance) => instance.paused); }, + get waiting() { + return instances.some((instance) => instance.waiting); + }, get count() { return instances.length; },