pagy_infinite_scroll 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4030148cd309dc18ac97b40df152d44d8b6ba1895be2998c4d5f1a31e3855171
4
+ data.tar.gz: 341f3089353a36671eca179db57c402f7b028649cf839b6fb30a4437046aa2e9
5
+ SHA512:
6
+ metadata.gz: efb49c31d7b1e5b665089e2332b4e37ea112faf763c9bbdacf95efe11968aaa08e3b5904f9a057ee97e2572bb15bc1bd70ee7547f0546b8e698d54851093f61d
7
+ data.tar.gz: 0ce1a7f8f8bd6bbbd1d4cda4f5fbc53def5b850c6e8adc683e46af8934e7544dca9b6855bd9616ccdb86fcef84c21213ca5322f0446fc28cec74d80f68a5a31a
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Hassan Haroon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,330 @@
1
+ # PagyInfiniteScroll
2
+
3
+ A Rails gem that adds infinite scroll functionality using Pagy and Stimulus. Load initial records and automatically fetch more as users scroll down - perfect for optimizing pages with large datasets.
4
+
5
+ ## Features
6
+
7
+ - 🚀 Easy integration with existing Rails apps
8
+ - 📦 Built on Pagy (fast, lightweight pagination)
9
+ - ⚡ Stimulus controller for smooth infinite scrolling
10
+ - 🎨 Customizable HTML rendering
11
+ - 🔧 Configurable scroll threshold and items per page
12
+ - 💾 Preserves URL parameters during scroll
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem 'pagy_infinite_scroll', github: 'hassanharoon86/pagy_infinite_scroll'
20
+ ```
21
+
22
+ Then run:
23
+
24
+ ```bash
25
+ bundle install
26
+ rails generate pagy_infinite_scroll:install
27
+ ```
28
+
29
+ The generator will automatically detect your JavaScript setup and configure accordingly:
30
+
31
+ ### For importmap-rails (auto-configured ✅)
32
+ The generator will:
33
+ - Add pin to `config/importmap.rb`
34
+ - Add import to `app/javascript/application.js`
35
+ - Auto-register controller with Stimulus
36
+ - **No manual steps needed!**
37
+
38
+ ### For jsbundling-rails (esbuild/webpack/rollup)
39
+ The generator will:
40
+ - Create `config/initializers/pagy_infinite_scroll.rb`
41
+ - Copy controller to `app/javascript/controllers/pagy_infinite_scroll_controller.js`
42
+ - Display registration instructions
43
+ - Run `yarn build` after setup
44
+
45
+ ## Quick Start
46
+
47
+ ### 1. Controller Setup
48
+
49
+ Use the gem's helper methods:
50
+
51
+ ```ruby
52
+ class ProductsController < ApplicationController
53
+ def index
54
+ # Use pagy_infinite_scroll instead of regular pagy
55
+ @pagy, @products = pagy_infinite_scroll(Product.all, limit: 50)
56
+
57
+ respond_to do |format|
58
+ format.html
59
+ format.json do
60
+ # Use the JSON helper to format response
61
+ render json: pagy_infinite_scroll_json(@pagy, @products) { |product|
62
+ {
63
+ id: product.id,
64
+ title: product.title,
65
+ price: product.price
66
+ }
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ ```
73
+
74
+ ### 2. View Setup
75
+
76
+ Add the infinite scroll container to your view:
77
+
78
+ ```erb
79
+ <div data-controller="pagy-infinite-scroll"
80
+ data-pagy-infinite-scroll-url-value="<%= products_path(format: :json) %>"
81
+ data-pagy-infinite-scroll-page-value="1"
82
+ data-pagy-infinite-scroll-has-more-value="<%= @pagy.next.present? %>"
83
+ style="max-height: 600px; overflow-y: auto;">
84
+
85
+ <!-- Container for items -->
86
+ <div data-pagy-infinite-scroll-target="itemsContainer">
87
+ <% @products.each do |product| %>
88
+ <div class="product-card">
89
+ <h3><%= product.title %></h3>
90
+ <p><%= product.price %></p>
91
+ </div>
92
+ <% end %>
93
+ </div>
94
+
95
+ <!-- Loading indicator -->
96
+ <div data-pagy-infinite-scroll-target="loadingIndicator" class="hidden">
97
+ <p>Loading more...</p>
98
+ </div>
99
+ </div>
100
+ ```
101
+
102
+ ## ⚠️ Important: Custom HTML Rendering
103
+
104
+ **The gem provides the core infinite scroll functionality, but you need to tell it how to render YOUR specific HTML.**
105
+
106
+ ### Why?
107
+
108
+ The gem cannot know:
109
+ - Your specific HTML structure (forms, checkboxes, badges, etc.)
110
+ - Your CSS classes and styling
111
+ - Your form field names
112
+ - Your Stimulus action targets
113
+
114
+ ### Solution: Extend the Controller
115
+
116
+ **For jsbundling-rails apps:**
117
+
118
+ Create a custom Stimulus controller that extends the gem's base controller:
119
+
120
+ ```javascript
121
+ // app/javascript/controllers/products_scroll_controller.js
122
+ import PagyInfiniteScrollController from "./pagy_infinite_scroll_controller"
123
+
124
+ export default class extends PagyInfiniteScrollController {
125
+ // Override this method to customize HTML for loaded items
126
+ createItemHTML(record) {
127
+ return `
128
+ <div class="product-card">
129
+ <h3>${record.title}</h3>
130
+ <p class="price">$${record.price}</p>
131
+ <span class="badge">${record.category}</span>
132
+ </div>
133
+ `
134
+ }
135
+ }
136
+ ```
137
+
138
+ Register it in `app/javascript/controllers/index.js`:
139
+
140
+ ```javascript
141
+ import ProductsScrollController from "./products_scroll_controller"
142
+ application.register("products-scroll", ProductsScrollController)
143
+ ```
144
+
145
+ **For importmap-rails apps:**
146
+
147
+ The controller is available globally as `window.PagyInfiniteScrollController`:
148
+
149
+ ```javascript
150
+ // app/javascript/controllers/products_scroll_controller.js
151
+ class ProductsScrollController extends window.PagyInfiniteScrollController {
152
+ createItemHTML(record) {
153
+ return `
154
+ <div class="product-card">
155
+ <h3>${record.title}</h3>
156
+ <p class="price">$${record.price}</p>
157
+ <span class="badge">${record.category}</span>
158
+ </div>
159
+ `
160
+ }
161
+ }
162
+
163
+ // Register with Stimulus
164
+ Stimulus.register("products-scroll", ProductsScrollController)
165
+ ```
166
+
167
+ Use in your view:
168
+
169
+ ```erb
170
+ <div data-controller="products-scroll"
171
+ data-products-scroll-url-value="<%= products_path(format: :json) %>"
172
+ data-products-scroll-page-value="1"
173
+ data-products-scroll-has-more-value="<%= @pagy.next.present? %>">
174
+ ...
175
+ </div>
176
+ ```
177
+
178
+ ### Real-World Example: Form with Checkboxes
179
+
180
+ ```javascript
181
+ // app/javascript/controllers/distribution_preferences_scroll_controller.js
182
+ import PagyInfiniteScrollController from "./pagy_infinite_scroll_controller"
183
+
184
+ export default class extends PagyInfiniteScrollController {
185
+ createItemHTML(record) {
186
+ const isChecked = record.selected ? 'checked' : ''
187
+
188
+ return `
189
+ <div class="flex items-center gap-4 p-4">
190
+ <input type="checkbox"
191
+ name="product_ids[]"
192
+ value="${record.id}"
193
+ id="product_${record.id}"
194
+ ${isChecked}
195
+ class="form-checkbox">
196
+
197
+ <label for="product_${record.id}" class="flex-1">
198
+ <div class="font-medium">${record.title}</div>
199
+ ${record.vendor_name ? `
200
+ <span class="badge">${record.vendor_name}</span>
201
+ ` : ''}
202
+ <span class="text-gray-500">
203
+ ${record.variants_count} variants
204
+ </span>
205
+ </label>
206
+ </div>
207
+ `
208
+ }
209
+ }
210
+ ```
211
+
212
+ **Key Points:**
213
+ - Only ~60 lines of custom JavaScript needed
214
+ - The gem handles all the scroll detection, AJAX, and state management
215
+ - You only define the HTML structure for your specific use case
216
+ - This keeps your code DRY and maintainable
217
+
218
+ ## Configuration
219
+
220
+ Edit `config/initializers/pagy_infinite_scroll.rb`:
221
+
222
+ ```ruby
223
+ PagyInfiniteScroll.configure do |config|
224
+ config.items_per_page = 50 # Items per page (default: 25)
225
+ config.scroll_threshold = 100 # Pixels from bottom to trigger load (default: 100)
226
+ config.loading_indicator = true # Show loading indicator (default: true)
227
+ config.preserve_state = true # Preserve URL params (default: true)
228
+ config.debounce_delay = 500 # Debounce for search in ms (default: 500)
229
+ end
230
+ ```
231
+
232
+ ## Data Attributes Reference
233
+
234
+ ### Container Attributes
235
+ - `data-pagy-infinite-scroll-url-value` - JSON endpoint URL (required)
236
+ - `data-pagy-infinite-scroll-page-value` - Current page number (default: 1)
237
+ - `data-pagy-infinite-scroll-has-more-value` - Has more pages? (required, true/false)
238
+ - `data-pagy-infinite-scroll-threshold-value` - Scroll threshold in pixels (optional)
239
+
240
+ ### Targets
241
+ - `data-pagy-infinite-scroll-target="itemsContainer"` - Where to append items
242
+ - `data-pagy-infinite-scroll-target="loadingIndicator"` - Loading indicator element
243
+
244
+ ## Events
245
+
246
+ The controller dispatches custom events you can listen to:
247
+
248
+ ```javascript
249
+ // In your custom controller
250
+ connect() {
251
+ super.connect()
252
+
253
+ this.element.addEventListener('pagy-infinite-scroll:loaded', (event) => {
254
+ console.log('Loaded page:', event.detail.page)
255
+ console.log('Has more:', event.detail.hasMore)
256
+ console.log('Count:', event.detail.count)
257
+ })
258
+
259
+ this.element.addEventListener('pagy-infinite-scroll:error', (event) => {
260
+ console.error('Error:', event.detail.error)
261
+ })
262
+ }
263
+ ```
264
+
265
+ ## Troubleshooting
266
+
267
+ ### Items not loading on scroll
268
+
269
+ 1. **Check browser console** for JavaScript errors
270
+ 2. **Verify JSON endpoint** returns correct format:
271
+ ```json
272
+ {
273
+ "records": [...],
274
+ "pagy": {
275
+ "page": 1,
276
+ "pages": 10,
277
+ "next": 2,
278
+ "count": 500
279
+ }
280
+ }
281
+ ```
282
+ 3. **Check Network tab** to see if AJAX requests are being made
283
+ 4. **Verify controller is connected** - should see console log on page load
284
+
285
+ ### Controller not found error
286
+
287
+ Run `yarn build` (or `npm run build`) to rebuild JavaScript bundle.
288
+
289
+ ### Helpers not available in controller
290
+
291
+ Restart your Rails server after installing the gem.
292
+
293
+ ### Items render as JSON instead of HTML
294
+
295
+ You need to create a custom controller that extends the base controller and overrides the `createItemHTML()` method (see "Custom HTML Rendering" section above).
296
+
297
+ ## JavaScript Setup: importmap vs jsbundling
298
+
299
+ This gem works with **both** importmap-rails and jsbundling-rails:
300
+
301
+ ### importmap-rails ✅
302
+ - **Setup**: Automatic via generator
303
+ - **Controller**: Standalone file, auto-registers with Stimulus
304
+ - **Extending**: Use `window.PagyInfiniteScrollController` as base class
305
+ - **Advantages**: Zero build step, simpler setup
306
+ - **File location**: Served from gem's assets
307
+
308
+ ### jsbundling-rails (esbuild/webpack/rollup) ✅
309
+ - **Setup**: Manual file copy (bundlers can't access gem paths)
310
+ - **Controller**: Import from `./pagy_infinite_scroll_controller`
311
+ - **Extending**: Use ES6 `import` and `extends`
312
+ - **Advantages**: Full ES6 module support, tree-shaking
313
+ - **File location**: `app/javascript/controllers/`
314
+
315
+ Both setups support the same features and API!
316
+
317
+ ## Requirements
318
+
319
+ - Rails 7.0+
320
+ - Pagy gem (add `gem 'pagy'` to your Gemfile)
321
+ - Stimulus (Hotwire)
322
+ - Either importmap-rails OR jsbundling-rails (esbuild, webpack, or rollup)
323
+
324
+ ## Contributing
325
+
326
+ Bug reports and pull requests are welcome on GitHub at https://github.com/hassanharoon86/pagy_infinite_scroll/issues
327
+
328
+ ## License
329
+
330
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,13 @@
1
+ // Pagy Infinite Scroll - Main Entry Point
2
+ // This file provides the controller for manual registration
3
+
4
+ import PagyInfiniteScrollController from "./infinite_scroll_controller"
5
+
6
+ // Export for manual registration in host app
7
+ export { PagyInfiniteScrollController }
8
+ export default PagyInfiniteScrollController
9
+
10
+ // Auto-register if Stimulus application is available globally
11
+ if (typeof window !== 'undefined' && window.Stimulus) {
12
+ window.Stimulus.register("pagy-infinite-scroll", PagyInfiniteScrollController)
13
+ }
@@ -0,0 +1,151 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Infinite Scroll Controller for Pagy
4
+ // Can be used standalone or extended
5
+ export default class PagyInfiniteScrollController extends Controller {
6
+ static targets = ['itemsContainer', 'loadingIndicator']
7
+ static values = {
8
+ url: String,
9
+ page: Number,
10
+ loading: Boolean,
11
+ hasMore: Boolean,
12
+ threshold: { type: Number, default: 100 },
13
+ preserveState: { type: Boolean, default: true }
14
+ }
15
+
16
+ connect() {
17
+ console.log('[PagyInfiniteScroll] Controller connected')
18
+ this.pageValue = this.pageValue || 1
19
+ this.loadingValue = false
20
+ this.boundHandleScroll = this.handleScroll.bind(this)
21
+ this.element.addEventListener('scroll', this.boundHandleScroll)
22
+ }
23
+
24
+ disconnect() {
25
+ this.element.removeEventListener('scroll', this.boundHandleScroll)
26
+ }
27
+
28
+ handleScroll() {
29
+ const scrollThreshold = this.hasThresholdValue ? this.thresholdValue : 100
30
+ const nearBottom = this.element.scrollHeight - this.element.scrollTop - this.element.clientHeight < scrollThreshold
31
+
32
+ if (nearBottom && !this.loadingValue && this.hasMoreValue) {
33
+ this.loadMore()
34
+ }
35
+ }
36
+
37
+ async loadMore() {
38
+ if (this.loadingValue || !this.hasMoreValue) return
39
+
40
+ console.log('[PagyInfiniteScroll] Loading more items...')
41
+ this.loadingValue = true
42
+ this.showLoading()
43
+
44
+ const nextPage = this.pageValue + 1
45
+
46
+ try {
47
+ const url = new URL(this.urlValue, window.location.origin)
48
+ url.searchParams.set('page', nextPage)
49
+
50
+ // Preserve current URL parameters
51
+ const currentParams = new URLSearchParams(window.location.search)
52
+ currentParams.forEach((value, key) => {
53
+ if (key !== 'page') {
54
+ url.searchParams.set(key, value)
55
+ }
56
+ })
57
+
58
+ const response = await fetch(url, {
59
+ headers: {
60
+ 'Accept': 'application/json',
61
+ 'X-Requested-With': 'XMLHttpRequest'
62
+ }
63
+ })
64
+
65
+ if (!response.ok) {
66
+ throw new Error(`HTTP error! status: ${response.status}`)
67
+ }
68
+
69
+ const data = await response.json()
70
+
71
+ // Dispatch event with data for custom handling
72
+ const event = this.dispatch('beforeAppend', {
73
+ detail: { data },
74
+ cancelable: true
75
+ })
76
+
77
+ if (!event.defaultPrevented) {
78
+ this.appendItems(data)
79
+ }
80
+
81
+ // Update pagination state
82
+ this.pageValue = data.pagy.page
83
+ this.hasMoreValue = data.pagy.next !== null
84
+
85
+ console.log(`[PagyInfiniteScroll] Loaded page ${this.pageValue}, has more: ${this.hasMoreValue}`)
86
+
87
+ // Dispatch success event
88
+ this.dispatch('loaded', {
89
+ detail: {
90
+ page: this.pageValue,
91
+ hasMore: this.hasMoreValue,
92
+ count: data.records.length
93
+ }
94
+ })
95
+
96
+ } catch (error) {
97
+ console.error('[PagyInfiniteScroll] Error loading more items:', error)
98
+ this.dispatch('error', { detail: { error } })
99
+ } finally {
100
+ this.loadingValue = false
101
+ this.hideLoading()
102
+ }
103
+ }
104
+
105
+ appendItems(data) {
106
+ if (!this.hasItemsContainerTarget) {
107
+ console.warn('[PagyInfiniteScroll] No items container target found')
108
+ return
109
+ }
110
+
111
+ const records = data.records || data.items || data.products || []
112
+
113
+ records.forEach(record => {
114
+ const html = this.createItemHTML(record)
115
+ this.itemsContainerTarget.insertAdjacentHTML('beforeend', html)
116
+ })
117
+ }
118
+
119
+ createItemHTML(record) {
120
+ // This method should be overridden by the application
121
+ // or use a custom event listener
122
+ console.warn('[PagyInfiniteScroll] createItemHTML should be overridden')
123
+ return `<div>${JSON.stringify(record)}</div>`
124
+ }
125
+
126
+ showLoading() {
127
+ if (this.hasLoadingIndicatorTarget) {
128
+ this.loadingIndicatorTarget.classList.remove('hidden')
129
+ }
130
+ }
131
+
132
+ hideLoading() {
133
+ if (this.hasLoadingIndicatorTarget) {
134
+ this.loadingIndicatorTarget.classList.add('hidden')
135
+ }
136
+ }
137
+
138
+ // Public API methods
139
+ reset() {
140
+ this.pageValue = 1
141
+ this.hasMoreValue = true
142
+ if (this.hasItemsContainerTarget) {
143
+ this.itemsContainerTarget.innerHTML = ''
144
+ }
145
+ }
146
+
147
+ reload() {
148
+ this.reset()
149
+ this.loadMore()
150
+ }
151
+ }
@@ -0,0 +1,177 @@
1
+ // Pagy Infinite Scroll Controller - Importmap Compatible Version
2
+ // This is a standalone file that works with both importmap-rails and jsbundling-rails
3
+ //
4
+ // For importmap-rails: This file will be pinned and auto-loaded
5
+ // For jsbundling-rails: Import from the modular version in app/javascript/controllers/
6
+
7
+ // IIFE to avoid polluting global scope while still working with importmap
8
+ (() => {
9
+ // Check if Stimulus is available
10
+ if (typeof Stimulus === 'undefined') {
11
+ console.error('[PagyInfiniteScroll] Stimulus is not loaded. Make sure @hotwired/stimulus is loaded before this controller.')
12
+ return
13
+ }
14
+
15
+ // Define the controller class
16
+ class PagyInfiniteScrollController extends Stimulus.Controller {
17
+ static targets = ['itemsContainer', 'loadingIndicator']
18
+ static values = {
19
+ url: String,
20
+ page: Number,
21
+ loading: Boolean,
22
+ hasMore: Boolean,
23
+ threshold: { type: Number, default: 100 },
24
+ preserveState: { type: Boolean, default: true }
25
+ }
26
+
27
+ connect() {
28
+ console.log('[PagyInfiniteScroll] Controller connected')
29
+ this.pageValue = this.pageValue || 1
30
+ this.loadingValue = false
31
+ this.boundHandleScroll = this.handleScroll.bind(this)
32
+ this.element.addEventListener('scroll', this.boundHandleScroll)
33
+ }
34
+
35
+ disconnect() {
36
+ this.element.removeEventListener('scroll', this.boundHandleScroll)
37
+ }
38
+
39
+ handleScroll() {
40
+ const scrollThreshold = this.hasThresholdValue ? this.thresholdValue : 100
41
+ const nearBottom = this.element.scrollHeight - this.element.scrollTop - this.element.clientHeight < scrollThreshold
42
+
43
+ if (nearBottom && !this.loadingValue && this.hasMoreValue) {
44
+ this.loadMore()
45
+ }
46
+ }
47
+
48
+ async loadMore() {
49
+ if (this.loadingValue || !this.hasMoreValue) return
50
+
51
+ console.log('[PagyInfiniteScroll] Loading more items...')
52
+ this.loadingValue = true
53
+ this.showLoading()
54
+
55
+ const nextPage = this.pageValue + 1
56
+
57
+ try {
58
+ const url = new URL(this.urlValue, window.location.origin)
59
+ url.searchParams.set('page', nextPage)
60
+
61
+ // Preserve current URL parameters
62
+ const currentParams = new URLSearchParams(window.location.search)
63
+ currentParams.forEach((value, key) => {
64
+ if (key !== 'page') {
65
+ url.searchParams.set(key, value)
66
+ }
67
+ })
68
+
69
+ const response = await fetch(url, {
70
+ headers: {
71
+ 'Accept': 'application/json',
72
+ 'X-Requested-With': 'XMLHttpRequest'
73
+ }
74
+ })
75
+
76
+ if (!response.ok) {
77
+ throw new Error(`HTTP error! status: ${response.status}`)
78
+ }
79
+
80
+ const data = await response.json()
81
+
82
+ // Dispatch event with data for custom handling
83
+ const event = this.dispatch('beforeAppend', {
84
+ detail: { data },
85
+ cancelable: true
86
+ })
87
+
88
+ if (!event.defaultPrevented) {
89
+ this.appendItems(data)
90
+ }
91
+
92
+ // Update pagination state
93
+ this.pageValue = data.pagy.page
94
+ this.hasMoreValue = data.pagy.next !== null
95
+
96
+ console.log(`[PagyInfiniteScroll] Loaded page ${this.pageValue}, has more: ${this.hasMoreValue}`)
97
+
98
+ // Dispatch success event
99
+ this.dispatch('loaded', {
100
+ detail: {
101
+ page: this.pageValue,
102
+ hasMore: this.hasMoreValue,
103
+ count: data.records.length
104
+ }
105
+ })
106
+
107
+ } catch (error) {
108
+ console.error('[PagyInfiniteScroll] Error loading more items:', error)
109
+ this.dispatch('error', { detail: { error } })
110
+ } finally {
111
+ this.loadingValue = false
112
+ this.hideLoading()
113
+ }
114
+ }
115
+
116
+ appendItems(data) {
117
+ if (!this.hasItemsContainerTarget) {
118
+ console.warn('[PagyInfiniteScroll] No items container target found')
119
+ return
120
+ }
121
+
122
+ const records = data.records || data.items || data.products || []
123
+
124
+ records.forEach(record => {
125
+ const html = this.createItemHTML(record)
126
+ this.itemsContainerTarget.insertAdjacentHTML('beforeend', html)
127
+ })
128
+ }
129
+
130
+ createItemHTML(record) {
131
+ // This method should be overridden by extending this controller
132
+ // or by listening to the 'beforeAppend' event
133
+ console.warn('[PagyInfiniteScroll] createItemHTML should be overridden in a custom controller')
134
+ return `<div class="pagy-item">${JSON.stringify(record)}</div>`
135
+ }
136
+
137
+ showLoading() {
138
+ if (this.hasLoadingIndicatorTarget) {
139
+ this.loadingIndicatorTarget.classList.remove('hidden')
140
+ }
141
+ }
142
+
143
+ hideLoading() {
144
+ if (this.hasLoadingIndicatorTarget) {
145
+ this.loadingIndicatorTarget.classList.add('hidden')
146
+ }
147
+ }
148
+
149
+ // Public API methods
150
+ reset() {
151
+ this.pageValue = 1
152
+ this.hasMoreValue = true
153
+ if (this.hasItemsContainerTarget) {
154
+ this.itemsContainerTarget.innerHTML = ''
155
+ }
156
+ }
157
+
158
+ reload() {
159
+ this.reset()
160
+ this.loadMore()
161
+ }
162
+ }
163
+
164
+ // Register the controller with Stimulus
165
+ if (window.Stimulus) {
166
+ window.Stimulus.register('pagy-infinite-scroll', PagyInfiniteScrollController)
167
+ console.log('[PagyInfiniteScroll] Controller registered with Stimulus')
168
+ }
169
+
170
+ // Also export for manual registration (jsbundling compatibility)
171
+ if (typeof module !== 'undefined' && module.exports) {
172
+ module.exports = PagyInfiniteScrollController
173
+ }
174
+
175
+ // Make available globally for importmap apps that want to extend it
176
+ window.PagyInfiniteScrollController = PagyInfiniteScrollController
177
+ })()
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module PagyInfiniteScroll
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc "Install Pagy Infinite Scroll into your application"
11
+
12
+ def create_initializer
13
+ template "initializer.rb", "config/initializers/pagy_infinite_scroll.rb"
14
+ end
15
+
16
+ def add_javascript_import
17
+ if using_importmap?
18
+ add_importmap_pin
19
+ elsif using_jsbundling?
20
+ add_jsbundling_import
21
+ else
22
+ say "Please manually import the Stimulus controller in your JavaScript setup", :yellow
23
+ end
24
+ end
25
+
26
+ def show_post_install_message
27
+ say "\n"
28
+ say "Pagy Infinite Scroll installed successfully!", :green
29
+ say "\n"
30
+ say "Next steps:", :yellow
31
+ say " 1. Make sure you have Pagy gem installed: gem 'pagy'"
32
+ say "\n"
33
+
34
+ if using_importmap?
35
+ say " 2. Importmap setup (already configured!):", :green
36
+ say " - Pin added to config/importmap.rb"
37
+ say " - Import added to app/javascript/application.js"
38
+ say " - Controller auto-registers with Stimulus on page load"
39
+ say " - No manual registration needed!"
40
+ elsif using_jsbundling?
41
+ say " 2. jsbundling setup:", :yellow
42
+ say " Copy the controller to your app:"
43
+ say " cp $(bundle show pagy_infinite_scroll)/app/assets/javascripts/pagy_infinite_scroll/infinite_scroll_controller.js app/javascript/controllers/pagy_infinite_scroll_controller.js"
44
+ say "\n"
45
+ say " Then register in app/javascript/controllers/index.js:"
46
+ say " import PagyInfiniteScrollController from './pagy_infinite_scroll_controller'"
47
+ say " application.register('pagy-infinite-scroll', PagyInfiniteScrollController)"
48
+ say "\n"
49
+ say " Finally run: yarn build (or npm run build)"
50
+ else
51
+ say " 2. Manual setup required", :yellow
52
+ say " See documentation for your JavaScript setup"
53
+ end
54
+
55
+ say "\n"
56
+ say " 3. Use in your controllers: pagy_infinite_scroll(collection)"
57
+ say " 4. See full documentation: https://github.com/hassanharoon86/pagy_infinite_scroll"
58
+ say "\n"
59
+ end
60
+
61
+ private
62
+
63
+ def using_importmap?
64
+ File.exist?("config/importmap.rb")
65
+ end
66
+
67
+ def using_jsbundling?
68
+ File.exist?("package.json") && (
69
+ File.exist?("app/javascript/application.js") ||
70
+ File.exist?("app/javascript/packs/application.js")
71
+ )
72
+ end
73
+
74
+ def add_importmap_pin
75
+ # Pin the standalone importmap-compatible controller
76
+ append_to_file "config/importmap.rb" do
77
+ "\n# Pagy Infinite Scroll - Standalone controller (auto-registers with Stimulus)\npin 'pagy_infinite_scroll_controller', to: 'pagy_infinite_scroll_controller.js'\n"
78
+ end
79
+
80
+ # Add import to application.js if it exists
81
+ if File.exist?("app/javascript/application.js")
82
+ append_to_file "app/javascript/application.js" do
83
+ "\n// Pagy Infinite Scroll - Auto-registers with Stimulus\nimport 'pagy_infinite_scroll_controller'\n"
84
+ end
85
+ end
86
+
87
+ say "\n"
88
+ say "Importmap configuration added!", :green
89
+ say "The controller will auto-register with Stimulus when loaded.", :green
90
+ say "\n"
91
+ end
92
+
93
+ def add_jsbundling_import
94
+ say "For jsbundling, you'll need to manually register the controller", :yellow
95
+ say "See the post-install message above for instructions", :yellow
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,25 @@
1
+ # Pagy Infinite Scroll Configuration
2
+ #
3
+ # Configure the default settings for infinite scroll pagination
4
+
5
+ PagyInfiniteScroll.configure do |config|
6
+ # Number of items to load per page
7
+ # Default: 50
8
+ config.items_per_page = 50
9
+
10
+ # Distance from bottom of container in pixels to trigger loading more items
11
+ # Default: 100
12
+ config.scroll_threshold = 100
13
+
14
+ # Show loading indicator while fetching new items
15
+ # Default: true
16
+ config.loading_indicator = true
17
+
18
+ # Preserve form state and URL parameters during infinite scroll
19
+ # Default: true
20
+ config.preserve_state = true
21
+
22
+ # Debounce delay for search in milliseconds
23
+ # Default: 500
24
+ config.debounce_delay = 500
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PagyInfiniteScroll
4
+ class Configuration
5
+ attr_accessor :items_per_page,
6
+ :scroll_threshold,
7
+ :loading_indicator,
8
+ :auto_initialize,
9
+ :preserve_state,
10
+ :debounce_delay
11
+
12
+ def initialize
13
+ @items_per_page = 50
14
+ @scroll_threshold = 100 # pixels from bottom
15
+ @loading_indicator = true
16
+ @auto_initialize = true
17
+ @preserve_state = true
18
+ @debounce_delay = 500 # milliseconds
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module PagyInfiniteScroll
6
+ module ControllerHelper
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ begin
11
+ require 'pagy'
12
+ include Pagy::Backend
13
+ rescue LoadError
14
+ raise LoadError, "Pagy gem is required. Add 'gem \"pagy\"' to your Gemfile and run 'bundle install'"
15
+ rescue NameError
16
+ raise NameError, "Pagy::Backend not found. Make sure Pagy is properly installed."
17
+ end
18
+ end
19
+
20
+ # Pagy with infinite scroll support
21
+ #
22
+ # @param collection [ActiveRecord::Relation] The collection to paginate
23
+ # @param options [Hash] Options for pagy and infinite scroll
24
+ # @return [Array<Pagy, Array>] The pagy object and paginated collection
25
+ #
26
+ # @example
27
+ # @pagy, @products = pagy_infinite_scroll(Product.all)
28
+ #
29
+ # @example With custom options
30
+ # @pagy, @products = pagy_infinite_scroll(Product.all, limit: 25)
31
+ #
32
+ def pagy_infinite_scroll(collection, **options)
33
+ options[:limit] ||= PagyInfiniteScroll.config.items_per_page
34
+ pagy, records = pagy(collection, **options)
35
+
36
+ [pagy, records]
37
+ end
38
+
39
+ # Respond with infinite scroll format
40
+ #
41
+ # @param pagy [Pagy] The pagy object
42
+ # @param records [Array] The paginated records
43
+ # @param serializer [Proc, nil] Optional custom serializer for records
44
+ # @yield [record] Block to serialize each record
45
+ # @return [Hash] JSON response with records and pagination data
46
+ #
47
+ # @example Basic usage
48
+ # respond_to do |format|
49
+ # format.html
50
+ # format.json do
51
+ # render json: pagy_infinite_scroll_json(@pagy, @products) { |product|
52
+ # {
53
+ # id: product.id,
54
+ # title: product.title,
55
+ # price: product.price
56
+ # }
57
+ # }
58
+ # end
59
+ # end
60
+ #
61
+ def pagy_infinite_scroll_json(pagy, records, &serializer)
62
+ {
63
+ records: serialize_records(records, &serializer),
64
+ pagy: {
65
+ page: pagy.page,
66
+ pages: pagy.pages,
67
+ count: pagy.count,
68
+ next: pagy.next,
69
+ prev: pagy.prev,
70
+ from: pagy.from,
71
+ to: pagy.to
72
+ }
73
+ }
74
+ end
75
+
76
+ private
77
+
78
+ def serialize_records(records, &serializer)
79
+ return records unless block_given?
80
+
81
+ records.map { |record| serializer.call(record) }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module PagyInfiniteScroll
6
+ class Engine < ::Rails::Engine
7
+ # Don't isolate namespace to allow helpers to load into main app
8
+ engine_name 'pagy_infinite_scroll'
9
+
10
+ # Ensure lib files are loaded
11
+ config.eager_load_paths << File.expand_path("../../", __FILE__)
12
+
13
+ # Load assets if asset pipeline is available
14
+ initializer "pagy_infinite_scroll.assets", before: :load_config_initializers do |app|
15
+ if app.config.respond_to?(:assets)
16
+ app.config.assets.paths << root.join("app/assets/javascripts")
17
+ app.config.assets.precompile += %w[
18
+ pagy_infinite_scroll/infinite_scroll_controller.js
19
+ pagy_infinite_scroll/index.js
20
+ pagy_infinite_scroll_controller.js
21
+ ]
22
+ end
23
+ end
24
+
25
+ # Support for importmap-rails
26
+ initializer "pagy_infinite_scroll.importmap", before: "importmap" do |app|
27
+ if defined?(Importmap)
28
+ # Make the standalone controller available to importmap
29
+ app.config.assets.paths << root.join("app/assets/javascripts") unless app.config.assets.paths.include?(root.join("app/assets/javascripts"))
30
+ end
31
+ end
32
+
33
+ # Load helpers into ActionController and ActionView
34
+ initializer "pagy_infinite_scroll.helpers" do
35
+ ActiveSupport.on_load(:action_controller_base) do
36
+ helper PagyInfiniteScroll::ViewHelper if defined?(PagyInfiniteScroll::ViewHelper)
37
+ include PagyInfiniteScroll::ControllerHelper if defined?(PagyInfiniteScroll::ControllerHelper)
38
+ end
39
+
40
+ ActiveSupport.on_load(:action_view) do
41
+ include PagyInfiniteScroll::ViewHelper if defined?(PagyInfiniteScroll::ViewHelper)
42
+ end
43
+ end
44
+
45
+ # Fallback for direct inclusion
46
+ config.after_initialize do
47
+ if defined?(ActionController::Base)
48
+ ActionController::Base.include(PagyInfiniteScroll::ControllerHelper) unless ActionController::Base.include?(PagyInfiniteScroll::ControllerHelper)
49
+ ActionController::Base.helper(PagyInfiniteScroll::ViewHelper) if defined?(PagyInfiniteScroll::ViewHelper)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PagyInfiniteScroll
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PagyInfiniteScroll
4
+ module ViewHelper
5
+ # Renders an infinite scroll container
6
+ #
7
+ # @param pagy [Pagy] The pagy object
8
+ # @param url [String] The URL to fetch more items
9
+ # @param options [Hash] HTML and Stimulus options
10
+ # @option options [String] :container_class CSS classes for the container
11
+ # @option options [String] :max_height Maximum height (e.g., "600px")
12
+ # @option options [Hash] :data Additional data attributes
13
+ # @yield The content to render inside the container
14
+ #
15
+ # @example
16
+ # <%= infinite_scroll_container(@pagy, products_path(format: :json)) do %>
17
+ # <div data-infinite-scroll-target="itemsContainer">
18
+ # <%= render @products %>
19
+ # </div>
20
+ # <% end %>
21
+ #
22
+ def infinite_scroll_container(pagy, url, options = {}, &block)
23
+ container_class = options[:container_class] || ''
24
+ max_height = options[:max_height] || '600px'
25
+ data_attrs = options[:data] || {}
26
+
27
+ content_tag :div,
28
+ class: container_class,
29
+ style: "max-height: #{max_height}; overflow-y: auto;",
30
+ data: {
31
+ controller: 'pagy-infinite-scroll',
32
+ pagy_infinite_scroll_url_value: url,
33
+ pagy_infinite_scroll_page_value: pagy.page,
34
+ pagy_infinite_scroll_loading_value: false,
35
+ pagy_infinite_scroll_has_more_value: pagy.next.present?,
36
+ **stimulus_data_attributes(data_attrs)
37
+ } do
38
+ block.call
39
+ end
40
+ end
41
+
42
+ # Renders the loading indicator
43
+ #
44
+ # @param options [Hash] Options for the loading indicator
45
+ # @option options [String] :text Loading text
46
+ # @option options [String] :class CSS classes
47
+ #
48
+ # @example
49
+ # <%= infinite_scroll_loading_indicator %>
50
+ #
51
+ # @example Custom text
52
+ # <%= infinite_scroll_loading_indicator(text: "Loading products...") %>
53
+ #
54
+ def infinite_scroll_loading_indicator(options = {})
55
+ text = options[:text] || 'Loading more items...'
56
+ css_class = options[:class] || 'hidden p-4 text-center'
57
+
58
+ content_tag :div,
59
+ class: css_class,
60
+ data: { pagy_infinite_scroll_target: 'loadingIndicator' } do
61
+ content_tag :div, class: 'inline-flex items-center gap-2 text-gray-600' do
62
+ concat(spinner_svg)
63
+ concat(content_tag(:span, text))
64
+ end
65
+ end
66
+ end
67
+
68
+ # Renders a scroll target container for items
69
+ #
70
+ # @param options [Hash] HTML options
71
+ # @option options [String] :class CSS classes
72
+ # @option options [String] :tag HTML tag (default: 'div')
73
+ # @yield The content to render inside the container
74
+ #
75
+ # @example
76
+ # <%= infinite_scroll_items_container do %>
77
+ # <%= render @products %>
78
+ # <% end %>
79
+ #
80
+ # @example With table rows
81
+ # <%= infinite_scroll_items_container(tag: 'tbody') do %>
82
+ # <%= render @items %>
83
+ # <% end %>
84
+ #
85
+ def infinite_scroll_items_container(options = {}, &block)
86
+ tag = options.delete(:tag) || 'div'
87
+ css_class = options.delete(:class) || ''
88
+
89
+ content_tag tag,
90
+ class: css_class,
91
+ data: { pagy_infinite_scroll_target: 'itemsContainer' } do
92
+ block.call
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def spinner_svg
99
+ <<~SVG.html_safe
100
+ <svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
101
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
102
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
103
+ </svg>
104
+ SVG
105
+ end
106
+
107
+ def stimulus_data_attributes(attrs)
108
+ attrs.transform_keys { |key| "pagy_infinite_scroll_#{key}_value".to_sym }
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pagy_infinite_scroll/version"
4
+ require_relative "pagy_infinite_scroll/configuration"
5
+ require_relative "pagy_infinite_scroll/controller_helper"
6
+ require_relative "pagy_infinite_scroll/view_helper"
7
+ require_relative "pagy_infinite_scroll/engine" if defined?(Rails)
8
+
9
+ module PagyInfiniteScroll
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ attr_accessor :configuration
14
+
15
+ def configure
16
+ self.configuration ||= Configuration.new
17
+ yield(configuration) if block_given?
18
+ end
19
+
20
+ def config
21
+ self.configuration ||= Configuration.new
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,4 @@
1
+ module PagyInfiniteScroll
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pagy_infinite_scroll
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hassan Haroon
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pagy
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec-rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '6.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '6.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: sqlite3
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.4'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.4'
82
+ description: A Rails gem that adds infinite scroll functionality to any Rails application
83
+ using Pagy for efficient pagination and Stimulus for smooth frontend interactions.
84
+ Features include automatic lazy loading, state preservation, AJAX support, and customizable
85
+ behavior.
86
+ email:
87
+ - hassanharoon86@gmail.com
88
+ executables: []
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - ".rspec"
93
+ - LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - app/assets/javascripts/pagy_infinite_scroll/index.js
97
+ - app/assets/javascripts/pagy_infinite_scroll/infinite_scroll_controller.js
98
+ - app/assets/javascripts/pagy_infinite_scroll_controller.js
99
+ - lib/generators/pagy_infinite_scroll/install/install_generator.rb
100
+ - lib/generators/pagy_infinite_scroll/install/templates/initializer.rb
101
+ - lib/pagy_infinite_scroll.rb
102
+ - lib/pagy_infinite_scroll/configuration.rb
103
+ - lib/pagy_infinite_scroll/controller_helper.rb
104
+ - lib/pagy_infinite_scroll/engine.rb
105
+ - lib/pagy_infinite_scroll/version.rb
106
+ - lib/pagy_infinite_scroll/view_helper.rb
107
+ - sig/pagy_infinite_scroll.rbs
108
+ homepage: https://github.com/hassanharoon86/pagy_infinite_scroll
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ homepage_uri: https://github.com/hassanharoon86/pagy_infinite_scroll
113
+ source_code_uri: https://github.com/hassanharoon86/pagy_infinite_scroll
114
+ changelog_uri: https://github.com/hassanharoon86/pagy_infinite_scroll/blob/main/CHANGELOG.md
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 3.0.0
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.6.7
130
+ specification_version: 4
131
+ summary: Infinite scroll pagination for Rails using Pagy and Stimulus
132
+ test_files: []