number_flow 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +68 -6
- data/app/assets/javascripts/number_flow/controller.js +40 -29
- data/app/components/number_flow/component.rb +30 -0
- data/lib/generators/number_flow/install/install_generator.rb +76 -0
- data/lib/number_flow/helper.rb +52 -8
- data/lib/number_flow/version.rb +1 -1
- data/lib/number_flow.rb +16 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b0cf284b80b9ebdd51127edf90746a9bf77e4f6c323f8dcd3917757ea82600f
|
|
4
|
+
data.tar.gz: 3f254dfe37f23d253477ab5bb574f0f76f982e82de16f36c6385102946991dc0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69d1fdc102f5c9b2a751c8988a4276e263bf8f9a289fb517db2d54750676fbda4fcf6eda6d70aaa1f3a2786f56938949023f13927918d2d4822007d3e5bbd31a
|
|
7
|
+
data.tar.gz: 6ef6f8f6f2f3a4627a1ebbc0192180df55a9a6387bff5649f12aac5d0e1eec65051636cdf5669b84b9fa3790b8f5b865804b8270848f3e7fbfd9c50b23e6e3c5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-07-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Decimal and fractional number animations with configurable precision and rollover behavior.
|
|
14
|
+
- Locale-aware number formatting powered by `Intl.NumberFormat`.
|
|
15
|
+
- Optional `NumberFlow::Component` integration and a Vite installer generator.
|
|
16
|
+
- Chromium/Cuprite browser coverage and continuous integration for browser behavior.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Improved negative-number and reduced-motion animation behavior.
|
|
20
|
+
- Documented the decision to keep Number Flow distributed as a Ruby gem.
|
|
21
|
+
|
|
22
|
+
## [0.1.2] - 2026-07-12
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Updated Rails-related lockfile dependencies, RuboCop, and Minitest.
|
|
26
|
+
|
|
10
27
|
## [0.1.1] - 2026-07-12
|
|
11
28
|
|
|
12
29
|
### Changed
|
data/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# NumberFlow
|
|
2
2
|
|
|
3
|
-
Rails helper + Stimulus controller for smooth
|
|
3
|
+
Rails helper + Stimulus controller for smooth number digit transitions (integers, decimals, locale-aware) inspired by [number-flow](https://github.com/barvian/number-flow).
|
|
4
4
|
|
|
5
|
+
[](https://github.com/dpaluy/number_flow/actions/workflows/ci.yml)
|
|
5
6
|
[](https://badge.fury.io/rb/number_flow)
|
|
6
7
|
|
|
7
8
|
`number_flow` ships both CSS and JavaScript assets inside the gem, so you do not need a separate npm package.
|
|
@@ -37,6 +38,8 @@ Add this in your layout (or import into your main stylesheet):
|
|
|
37
38
|
|
|
38
39
|
### 2) Register the Stimulus controller
|
|
39
40
|
|
|
41
|
+
#### Sprockets / Importmap
|
|
42
|
+
|
|
40
43
|
With importmap (`config/importmap.rb`):
|
|
41
44
|
|
|
42
45
|
```ruby
|
|
@@ -52,25 +55,56 @@ import NumberFlowController from "number_flow/controller"
|
|
|
52
55
|
application.register("number-flow", NumberFlowController)
|
|
53
56
|
```
|
|
54
57
|
|
|
58
|
+
#### Vite Rails
|
|
59
|
+
|
|
60
|
+
Use the built-in generator to copy assets into `app/frontend/`:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
rails g number_flow:install --vite
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This copies:
|
|
67
|
+
- `controller.js` → `app/frontend/controllers/number_flow_controller.js`
|
|
68
|
+
- `number_flow.css` → `app/frontend/stylesheets/number_flow.css`
|
|
69
|
+
|
|
70
|
+
If `app/frontend/entrypoints/application.js` exists, the controller import and registration are appended automatically (idempotent — re-running won't duplicate).
|
|
71
|
+
|
|
72
|
+
Without `--vite`, the generator prints the Sprockets/importmap setup instructions above.
|
|
73
|
+
|
|
55
74
|
## Usage
|
|
56
75
|
|
|
57
|
-
Render
|
|
76
|
+
Render an integer:
|
|
58
77
|
|
|
59
78
|
```erb
|
|
60
79
|
<%= number_flow_tag(12_345) %>
|
|
61
80
|
```
|
|
62
81
|
|
|
82
|
+
Render a decimal with precision:
|
|
83
|
+
|
|
84
|
+
```erb
|
|
85
|
+
<%= number_flow_tag(12.34, precision: 2) %>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Locale-aware formatting (uses `Intl.NumberFormat` on the client):
|
|
89
|
+
|
|
90
|
+
```erb
|
|
91
|
+
<%= number_flow_tag(1234.5, precision: 1, locale: "de-DE", grouping: true) %>
|
|
92
|
+
<!-- renders as 1.234,5 -->
|
|
93
|
+
```
|
|
94
|
+
|
|
63
95
|
Render with options:
|
|
64
96
|
|
|
65
97
|
```erb
|
|
66
98
|
<%= number_flow_tag(
|
|
67
|
-
12_345,
|
|
99
|
+
12_345.67,
|
|
100
|
+
precision: 2,
|
|
68
101
|
class: "kpi-value",
|
|
69
102
|
duration: 550,
|
|
70
103
|
easing: "ease-out",
|
|
71
104
|
stagger: 30,
|
|
72
105
|
grouping: true,
|
|
73
|
-
|
|
106
|
+
locale: "en-US",
|
|
107
|
+
aria_label: "Current total revenue"
|
|
74
108
|
) %>
|
|
75
109
|
```
|
|
76
110
|
|
|
@@ -80,7 +114,7 @@ Update from JavaScript:
|
|
|
80
114
|
const element = document.querySelector("[data-controller='number-flow']")
|
|
81
115
|
element.dispatchEvent(
|
|
82
116
|
new CustomEvent("number-flow:update", {
|
|
83
|
-
detail: { value: 12987 }
|
|
117
|
+
detail: { value: 12987.50 }
|
|
84
118
|
})
|
|
85
119
|
)
|
|
86
120
|
```
|
|
@@ -96,9 +130,21 @@ Options:
|
|
|
96
130
|
- `easing:` CSS easing function (default: `cubic-bezier(0.2, 0, 0, 1)`)
|
|
97
131
|
- `stagger:` per-digit delay in milliseconds (default: `20`)
|
|
98
132
|
- `grouping:` show thousands separators (default: `false`)
|
|
133
|
+
- `precision:` number of decimal digits (default: `0` — integer mode)
|
|
134
|
+
- `locale:` BCP 47 locale tag for formatting (default: `nil` → browser default)
|
|
99
135
|
- `aria_label:` explicit `aria-label`
|
|
100
136
|
- `data:` additional data attributes merged into the root element
|
|
101
137
|
|
|
138
|
+
## Optional ViewComponent Wrapper
|
|
139
|
+
|
|
140
|
+
If your app uses [ViewComponent](https://github.com/github/view_component), `NumberFlow::Component` is available as a thin wrapper:
|
|
141
|
+
|
|
142
|
+
```erb
|
|
143
|
+
<%= render(NumberFlow::Component.new(value: 42, precision: 2)) %>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The component is only defined when the `view_component` gem is present. The gem does **not** add `view_component` as a runtime dependency. Add it to your app's Gemfile to use the component API.
|
|
147
|
+
|
|
102
148
|
## Accessibility
|
|
103
149
|
|
|
104
150
|
- Root element uses `role="status"` and `aria-live="polite"`.
|
|
@@ -106,7 +152,17 @@ Options:
|
|
|
106
152
|
|
|
107
153
|
## Error Handling
|
|
108
154
|
|
|
109
|
-
`number_flow_tag` raises `ArgumentError` when `value` cannot be coerced into
|
|
155
|
+
`number_flow_tag` raises `ArgumentError` when `value` cannot be coerced into a numeric value.
|
|
156
|
+
|
|
157
|
+
## Why No npm Package?
|
|
158
|
+
|
|
159
|
+
**Decision: gem-only distribution.** The Stimulus controller consumes data attributes (`data-number-flow-value-value`, `data-number-flow-precision-value`, etc.) that are emitted by the Ruby helper. The JS is not standalone — it depends on the gem's markup contract. Publishing to npm would:
|
|
160
|
+
|
|
161
|
+
1. **Double maintenance** for no standalone value — two version tracks, two release workflows, two changelogs.
|
|
162
|
+
2. **Not solve a real problem** — Vite/importmap integration (via the install generator) already covers asset resolution without npm.
|
|
163
|
+
3. **Conflict with positioning** — the gem already markets gem-only as a feature.
|
|
164
|
+
|
|
165
|
+
**Revisit trigger:** if a framework-agnostic core (pure formatting + DOM, no Rails-helper coupling) is extracted, or if community demand materializes for importing the controller from `@dpaluy/number_flow` in a non-Rails context. Until then: won't ship.
|
|
110
166
|
|
|
111
167
|
## Development
|
|
112
168
|
|
|
@@ -115,6 +171,12 @@ bundle exec rake test
|
|
|
115
171
|
bundle exec rubocop
|
|
116
172
|
```
|
|
117
173
|
|
|
174
|
+
Browser system tests require Chrome/Chromium:
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
RUN_SYSTEM_TESTS=true bundle exec rake test
|
|
178
|
+
```
|
|
179
|
+
|
|
118
180
|
## Contributing
|
|
119
181
|
|
|
120
182
|
Bug reports and pull requests are welcome on [GitHub](https://github.com/dpaluy/number_flow/issues).
|
|
@@ -8,7 +8,9 @@ export default class extends Controller {
|
|
|
8
8
|
duration: Number,
|
|
9
9
|
easing: String,
|
|
10
10
|
stagger: Number,
|
|
11
|
-
grouping: Boolean
|
|
11
|
+
grouping: Boolean,
|
|
12
|
+
precision: Number,
|
|
13
|
+
locale: String
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
connect() {
|
|
@@ -41,41 +43,33 @@ export default class extends Controller {
|
|
|
41
43
|
return
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
this.updateTo(
|
|
46
|
+
this.updateTo(nextValue)
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
updateTo(nextValue) {
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
+
if (nextValue === this.currentValue) return
|
|
51
|
+
if (!this.currentParts) return // Guard: not yet connected
|
|
50
52
|
|
|
51
53
|
this.applyTimingVariables()
|
|
52
|
-
this.render(
|
|
53
|
-
this.currentValue =
|
|
54
|
-
this.valueValue =
|
|
54
|
+
this.render(nextValue, !this.prefersReducedMotion())
|
|
55
|
+
this.currentValue = nextValue
|
|
56
|
+
this.valueValue = nextValue
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
render(value, animate) {
|
|
58
|
-
const
|
|
60
|
+
const previousParts = this.currentParts
|
|
59
61
|
const nextParts = this.partsFor(value)
|
|
60
|
-
const nextDigits = nextParts.filter((part) => part.kind === "digit").map((part) => part.value)
|
|
61
|
-
|
|
62
|
-
let digitIndex = 0
|
|
63
|
-
const totalDigits = nextDigits.length
|
|
64
62
|
const fragment = document.createDocumentFragment()
|
|
65
63
|
|
|
66
|
-
nextParts.forEach((part) => {
|
|
64
|
+
nextParts.forEach((part, index) => {
|
|
67
65
|
if (part.kind === "separator") {
|
|
68
66
|
fragment.appendChild(this.buildSeparatorNode(part.value))
|
|
69
67
|
return
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const toDigit = part.value
|
|
76
|
-
const indexFromRight = totalDigits - digitIndex - 1
|
|
77
|
-
fragment.appendChild(this.buildDigitNode(fromDigit, toDigit, indexFromRight, animate))
|
|
78
|
-
digitIndex += 1
|
|
70
|
+
const { fromDigit } = this.alignDigit(previousParts, nextParts, index)
|
|
71
|
+
const indexFromRight = nextParts.length - index - 1
|
|
72
|
+
fragment.appendChild(this.buildDigitNode(fromDigit, part.value, indexFromRight, animate))
|
|
79
73
|
})
|
|
80
74
|
|
|
81
75
|
this.element.replaceChildren(fragment)
|
|
@@ -92,6 +86,20 @@ export default class extends Controller {
|
|
|
92
86
|
this.currentParts = nextParts
|
|
93
87
|
}
|
|
94
88
|
|
|
89
|
+
alignDigit(previousParts, nextParts, currentIndex) {
|
|
90
|
+
const nextDigits = nextParts.filter((p) => p.kind === "digit")
|
|
91
|
+
const previousDigits = previousParts.filter((p) => p.kind === "digit")
|
|
92
|
+
|
|
93
|
+
const currentDigitIndex = nextParts.slice(0, currentIndex).filter((p) => p.kind === "digit").length
|
|
94
|
+
const remaining = nextDigits.length - currentDigitIndex
|
|
95
|
+
const previousIndex = previousDigits.length - remaining
|
|
96
|
+
|
|
97
|
+
const fromDigit = previousIndex >= 0 ? previousDigits[previousIndex].value : 0
|
|
98
|
+
const toDigit = nextParts[currentIndex].value
|
|
99
|
+
|
|
100
|
+
return { fromDigit, toDigit }
|
|
101
|
+
}
|
|
102
|
+
|
|
95
103
|
partsFor(value) {
|
|
96
104
|
return this.formattedString(value).split("").map((char) => {
|
|
97
105
|
if (/\d/.test(char)) {
|
|
@@ -103,10 +111,14 @@ export default class extends Controller {
|
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
formattedString(value) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
const formatter = new Intl.NumberFormat(this.localeValue || undefined, {
|
|
115
|
+
useGrouping: this.groupingValue,
|
|
116
|
+
minimumFractionDigits: this.precisionValue,
|
|
117
|
+
maximumFractionDigits: this.precisionValue
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const formatted = formatter.format(Math.abs(value))
|
|
121
|
+
return value < 0 ? `-${formatted}` : formatted
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
buildDigitNode(fromDigit, toDigit, indexFromRight, animate) {
|
|
@@ -150,19 +162,19 @@ export default class extends Controller {
|
|
|
150
162
|
|
|
151
163
|
initialValue() {
|
|
152
164
|
if (this.hasValueValue && Number.isFinite(this.valueValue)) {
|
|
153
|
-
return
|
|
165
|
+
return this.valueValue
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
const inlineValue = Number(this.element.getAttribute("data-number-flow-value-value"))
|
|
157
169
|
if (Number.isFinite(inlineValue)) {
|
|
158
|
-
return
|
|
170
|
+
return inlineValue
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
return 0
|
|
162
174
|
}
|
|
163
175
|
|
|
164
176
|
prefersReducedMotion() {
|
|
165
|
-
return this.reducedMotionMedia
|
|
177
|
+
return this.reducedMotionMedia?.matches === true
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
durationOrDefault() {
|
|
@@ -170,7 +182,7 @@ export default class extends Controller {
|
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
easingOrDefault() {
|
|
173
|
-
return this.hasEasingValue ? this.easingValue : "cubic-bezier(0.2, 0,
|
|
185
|
+
return this.hasEasingValue ? this.easingValue : "cubic-bezier(0.2, 0, 1)"
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
staggerOrDefault() {
|
|
@@ -181,4 +193,3 @@ export default class extends Controller {
|
|
|
181
193
|
return typeof document !== "undefined" && document.location?.hostname === "localhost"
|
|
182
194
|
}
|
|
183
195
|
}
|
|
184
|
-
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'number_flow/helper'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'view_component'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# ViewComponent is not installed — Component is not defined.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module NumberFlow
|
|
12
|
+
# Optional ViewComponent wrapper around the +number_flow_tag+ helper.
|
|
13
|
+
# Only defined when the +view_component+ gem is available, so the gem
|
|
14
|
+
# never forces a hard dependency.
|
|
15
|
+
if defined?(::ViewComponent)
|
|
16
|
+
class Component < ::ViewComponent::Base
|
|
17
|
+
include NumberFlow::Helper
|
|
18
|
+
|
|
19
|
+
def initialize(value:, **options)
|
|
20
|
+
@value = value
|
|
21
|
+
@options = options
|
|
22
|
+
super()
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
number_flow_tag(@value, **@options)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/base'
|
|
5
|
+
|
|
6
|
+
module NumberFlow
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
desc 'Install NumberFlow for your asset pipeline (Sprockets, importmap, or Vite Rails).'
|
|
10
|
+
|
|
11
|
+
class_option :vite, type: :boolean, default: false,
|
|
12
|
+
desc: 'Copy controller and stylesheet into app/frontend for Vite Rails'
|
|
13
|
+
|
|
14
|
+
source_root File.expand_path('../../../..', __dir__)
|
|
15
|
+
|
|
16
|
+
CONTROLLER_SOURCE = 'app/assets/javascripts/number_flow/controller.js'
|
|
17
|
+
CSS_SOURCE = 'app/assets/stylesheets/number_flow.css'
|
|
18
|
+
VITE_CONTROLLER_TARGET = 'app/frontend/controllers/number_flow_controller.js'
|
|
19
|
+
VITE_CSS_TARGET = 'app/frontend/stylesheets/number_flow.css'
|
|
20
|
+
VITE_ENTRYPOINT = 'app/frontend/entrypoints/application.js'
|
|
21
|
+
|
|
22
|
+
def install
|
|
23
|
+
if options[:vite]
|
|
24
|
+
install_vite
|
|
25
|
+
else
|
|
26
|
+
install_standard
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def install_vite
|
|
33
|
+
say_status('info', 'Installing NumberFlow for Vite Rails', :blue)
|
|
34
|
+
|
|
35
|
+
copy_file(CONTROLLER_SOURCE, VITE_CONTROLLER_TARGET)
|
|
36
|
+
copy_file(CSS_SOURCE, VITE_CSS_TARGET)
|
|
37
|
+
register_stimulus_controller
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def install_standard
|
|
41
|
+
say_status('info', 'NumberFlow assets are served from the gem (Sprockets/importmap).', :blue)
|
|
42
|
+
say('')
|
|
43
|
+
say('1. Load the stylesheet in your layout:')
|
|
44
|
+
say(' <%= stylesheet_link_tag "number_flow", "data-turbo-track": "reload" %>')
|
|
45
|
+
say('')
|
|
46
|
+
say('2. Pin the controller (config/importmap.rb):')
|
|
47
|
+
say(' pin "number_flow/controller", to: "number_flow/controller.js"')
|
|
48
|
+
say('')
|
|
49
|
+
say('3. Register in app/javascript/controllers/index.js:')
|
|
50
|
+
say(' import NumberFlowController from "number_flow/controller"')
|
|
51
|
+
say(' application.register("number-flow", NumberFlowController)')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def register_stimulus_controller
|
|
55
|
+
return unless File.exist?(File.expand_path(VITE_ENTRYPOINT, destination_root))
|
|
56
|
+
|
|
57
|
+
entrypoint_path = File.expand_path(VITE_ENTRYPOINT, destination_root)
|
|
58
|
+
content = File.read(entrypoint_path)
|
|
59
|
+
|
|
60
|
+
import_line = 'import NumberFlowController from "../controllers/number_flow_controller"'
|
|
61
|
+
register_line = 'application.register("number-flow", NumberFlowController)'
|
|
62
|
+
|
|
63
|
+
return if content.include?(import_line)
|
|
64
|
+
|
|
65
|
+
inject_into_file(VITE_ENTRYPOINT, " #{import_line}\n", after: /import application from.*\n/)
|
|
66
|
+
|
|
67
|
+
unless content.include?(register_line)
|
|
68
|
+
inject_into_file(VITE_ENTRYPOINT, " #{register_line}\n",
|
|
69
|
+
after: /application = Application\.start.*\n/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
say_status('create', "#{VITE_ENTRYPOINT} (updated)", :green)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/number_flow/helper.rb
CHANGED
|
@@ -14,12 +14,14 @@ module NumberFlow
|
|
|
14
14
|
easing: DEFAULT_EASING,
|
|
15
15
|
stagger: DEFAULT_STAGGER,
|
|
16
16
|
grouping: false,
|
|
17
|
+
precision: 0,
|
|
18
|
+
locale: nil,
|
|
17
19
|
aria_label: nil,
|
|
18
20
|
data: {},
|
|
19
21
|
**html_options
|
|
20
22
|
)
|
|
21
|
-
normalized_value =
|
|
22
|
-
formatted_value =
|
|
23
|
+
normalized_value = coerce_number(value)
|
|
24
|
+
formatted_value = format_number(normalized_value, precision: precision, grouping: grouping, locale: locale)
|
|
23
25
|
css_class = html_options.delete(:class)
|
|
24
26
|
extra_data = html_options.delete(:data)
|
|
25
27
|
|
|
@@ -38,7 +40,9 @@ module NumberFlow
|
|
|
38
40
|
number_flow_duration_value: Integer(duration),
|
|
39
41
|
number_flow_easing_value: easing.to_s,
|
|
40
42
|
number_flow_stagger_value: Integer(stagger),
|
|
41
|
-
number_flow_grouping_value: grouping == true
|
|
43
|
+
number_flow_grouping_value: grouping == true,
|
|
44
|
+
number_flow_precision_value: precision.positive? ? Integer(precision) : nil,
|
|
45
|
+
number_flow_locale_value: locale
|
|
42
46
|
},
|
|
43
47
|
normalize_data_hash(data),
|
|
44
48
|
normalize_data_hash(extra_data)
|
|
@@ -49,11 +53,22 @@ module NumberFlow
|
|
|
49
53
|
safe_join(formatted_value.chars.map { |char| build_character_fragment(char) })
|
|
50
54
|
end
|
|
51
55
|
rescue ArgumentError, TypeError
|
|
52
|
-
raise ArgumentError, 'number_flow_tag expects
|
|
56
|
+
raise ArgumentError, 'number_flow_tag expects a numeric value'
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
private
|
|
56
60
|
|
|
61
|
+
def coerce_number(value)
|
|
62
|
+
case value
|
|
63
|
+
when Integer, Float
|
|
64
|
+
value
|
|
65
|
+
when String
|
|
66
|
+
Float(value)
|
|
67
|
+
else
|
|
68
|
+
value.respond_to?(:to_f) ? Float(value.to_f) : (raise ArgumentError)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
def build_character_fragment(char)
|
|
58
73
|
if char.match?(/\d/)
|
|
59
74
|
build_digit_fragment(char.to_i)
|
|
@@ -80,11 +95,40 @@ module NumberFlow
|
|
|
80
95
|
end
|
|
81
96
|
end
|
|
82
97
|
|
|
83
|
-
def
|
|
98
|
+
def format_number(value, precision:, grouping:, locale:)
|
|
84
99
|
sign = value.negative? ? '-' : ''
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
abs_value = value.abs
|
|
101
|
+
decimal_sep = locale_decimal_separator(locale)
|
|
102
|
+
group_sep = locale_group_separator(locale)
|
|
103
|
+
|
|
104
|
+
if precision.positive?
|
|
105
|
+
integer_part = Integer(abs_value.floor)
|
|
106
|
+
fraction = format('%.0f', (abs_value - integer_part) * (10**precision)).rjust(precision, '0')
|
|
107
|
+
number_str = "#{group_digits(integer_part, grouping, group_sep)}#{decimal_sep}#{fraction}"
|
|
108
|
+
else
|
|
109
|
+
number_str = group_digits(Integer(abs_value), grouping, group_sep)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
"#{sign}#{number_str}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def group_digits(integer, grouping, separator)
|
|
116
|
+
digits = integer.to_s
|
|
117
|
+
return digits unless grouping
|
|
118
|
+
|
|
119
|
+
digits.reverse.scan(/.{1,3}/).join(separator).reverse
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def locale_decimal_separator(locale)
|
|
123
|
+
return ',' if locale && !locale.start_with?('en')
|
|
124
|
+
|
|
125
|
+
'.'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def locale_group_separator(locale)
|
|
129
|
+
return '.' if locale && !locale.start_with?('en')
|
|
130
|
+
|
|
131
|
+
','
|
|
88
132
|
end
|
|
89
133
|
|
|
90
134
|
def merged_data_attributes(*hashes)
|
data/lib/number_flow/version.rb
CHANGED
data/lib/number_flow.rb
CHANGED
|
@@ -7,3 +7,19 @@ require_relative 'number_flow/engine'
|
|
|
7
7
|
module NumberFlow
|
|
8
8
|
class Error < StandardError; end
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
# Load optional ViewComponent wrapper if the gem is present.
|
|
12
|
+
begin
|
|
13
|
+
require 'view_component'
|
|
14
|
+
require_relative '../app/components/number_flow/component'
|
|
15
|
+
rescue LoadError
|
|
16
|
+
# ViewComponent is not installed — Component stays undefined.
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Load Rails generators when Rails is available.
|
|
20
|
+
begin
|
|
21
|
+
require 'rails'
|
|
22
|
+
require_relative 'generators/number_flow/install/install_generator'
|
|
23
|
+
rescue LoadError
|
|
24
|
+
# Rails is not available — generators are not loaded.
|
|
25
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: number_flow
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dpaluy
|
|
@@ -37,8 +37,8 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: 8.1.0
|
|
40
|
-
description: NumberFlow provides a Rails helper and Stimulus controller for smooth
|
|
41
|
-
|
|
40
|
+
description: NumberFlow provides a Rails helper and Stimulus controller for smooth,
|
|
41
|
+
locale-aware number transitions using gem-shipped CSS and JavaScript assets.
|
|
42
42
|
email:
|
|
43
43
|
- dpaluy@users.noreply.github.com
|
|
44
44
|
executables: []
|
|
@@ -53,6 +53,8 @@ files:
|
|
|
53
53
|
- README.md
|
|
54
54
|
- app/assets/javascripts/number_flow/controller.js
|
|
55
55
|
- app/assets/stylesheets/number_flow.css
|
|
56
|
+
- app/components/number_flow/component.rb
|
|
57
|
+
- lib/generators/number_flow/install/install_generator.rb
|
|
56
58
|
- lib/number_flow.rb
|
|
57
59
|
- lib/number_flow/engine.rb
|
|
58
60
|
- lib/number_flow/helper.rb
|
|
@@ -83,5 +85,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
83
85
|
requirements: []
|
|
84
86
|
rubygems_version: 3.6.9
|
|
85
87
|
specification_version: 4
|
|
86
|
-
summary: Rails helper + Stimulus
|
|
88
|
+
summary: Rails helper + Stimulus transitions for animated numbers.
|
|
87
89
|
test_files: []
|