@brandocms/jupiter 5.0.0-beta.11 → 5.0.0-beta.13

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.
package/README.md CHANGED
@@ -445,6 +445,7 @@ Components can be placed outside the main dataloader element:
445
445
  - `page` - `number` - Initial page number (default: 0)
446
446
  - `loaderParam` - `object` - Initial parameters
447
447
  - `filter` - `string` - Initial filter value
448
+ - `filterDebounce` - `number` - Debounce delay in ms for filter input (default: 650)
448
449
  - `onFetch` - `function` - Called after content is fetched
449
450
  - `urlSync` - `object` - URL synchronization configuration:
450
451
  - `templates` - Language-specific URL templates with `:param` placeholders
@@ -472,6 +473,12 @@ Components can be placed outside the main dataloader element:
472
473
  - `data-loader-loading` - Added during loading
473
474
  - `data-loader-starved` - Added to load more button when no more content
474
475
 
476
+ ### Methods
477
+
478
+ - `destroy()` - Remove all event listeners, abort pending fetches, and clean up URL sync
479
+ - `updateBaseURL(url)` - Change the base API URL
480
+ - `Dataloader.replaceInnerHTML(el, url)` - Static method to replace an element's innerHTML with fetched content
481
+
475
482
  ### API Response Headers
476
483
 
477
484
  - `jpt-dataloader: starved` - Set this header when there's no more content to load
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandocms/jupiter",
3
- "version": "5.0.0-beta.11",
3
+ "version": "5.0.0-beta.13",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -10,7 +10,7 @@ import Fontloader from '../Fontloader'
10
10
  import Dom from '../Dom'
11
11
  import { set, clearProps } from '../../utils/motion-helpers'
12
12
 
13
- window.onpageshow = event => {
13
+ window.addEventListener('pageshow', event => {
14
14
  // Fix for hash anchor navigation issues
15
15
  // When navigating to a page with #anchor, ensure overlay is removed and content is visible
16
16
  const hasHash = window.location.hash
@@ -60,7 +60,7 @@ window.onpageshow = event => {
60
60
  setTimeout(fixVisibility, 500)
61
61
  }
62
62
  }
63
- }
63
+ })
64
64
 
65
65
  const DEFAULT_OPTIONS = {
66
66
  respectReducedMotion: false,
@@ -123,8 +123,8 @@ export default class Application {
123
123
  this.debugType = 1
124
124
  this.debugOverlay = null
125
125
  this.userAgent = navigator.userAgent
126
- this._lastWindowHeight = 0
127
126
  this.breakpoint = null
127
+ this.root = document.documentElement
128
128
  this.language = document.documentElement.lang
129
129
 
130
130
  this.size = {
@@ -160,6 +160,7 @@ export default class Application {
160
160
  this.opts.breakpointConfig = breakpointConfig || DEFAULT_OPTIONS.breakpointConfig
161
161
 
162
162
  this.focusableSelectors = this.opts.focusableSelectors
163
+ this.browser = null
163
164
  this.featureTests = new FeatureTests(this, this.opts.featureTests)
164
165
 
165
166
  if (typeof this.opts.breakpointConfig === 'object') {
@@ -174,7 +175,6 @@ export default class Application {
174
175
  this.setDims()
175
176
  this.fontLoader = new Fontloader(this)
176
177
 
177
- this.fader = null
178
178
  this.callbacks = {}
179
179
 
180
180
  this.SCROLL_LOCKED = false
@@ -190,10 +190,10 @@ export default class Application {
190
190
  }
191
191
  window.addEventListener(Events.BREAKPOINT_CHANGE, this.onBreakpointChanged.bind(this))
192
192
 
193
- this.beforeInitializedEvent = new window.CustomEvent(Events.APPLICATION_PRELUDIUM, this)
194
- this.initializedEvent = new window.CustomEvent(Events.APPLICATION_INITIALIZED, this)
195
- this.readyEvent = new window.CustomEvent(Events.APPLICATION_READY, this)
196
- this.revealedEvent = new window.CustomEvent(Events.APPLICATION_REVEALED, this)
193
+ this.beforeInitializedEvent = new window.CustomEvent(Events.APPLICATION_PRELUDIUM, { detail: this })
194
+ this.initializedEvent = new window.CustomEvent(Events.APPLICATION_INITIALIZED, { detail: this })
195
+ this.readyEvent = new window.CustomEvent(Events.APPLICATION_READY, { detail: this })
196
+ this.revealedEvent = new window.CustomEvent(Events.APPLICATION_REVEALED, { detail: this })
197
197
 
198
198
  /**
199
199
  * Grab common events and defer
@@ -204,14 +204,14 @@ export default class Application {
204
204
  passive: true,
205
205
  })
206
206
 
207
- if (opts.bindScroll) {
207
+ if (this.opts.bindScroll) {
208
208
  window.addEventListener('scroll', rafCallback(this.onScroll.bind(this)), {
209
209
  capture: false,
210
210
  passive: true,
211
211
  })
212
212
  }
213
213
 
214
- if (opts.bindResize) {
214
+ if (this.opts.bindResize) {
215
215
  window.addEventListener('resize', rafCallback(this.onResize.bind(this)), {
216
216
  capture: false,
217
217
  passive: true,
@@ -364,7 +364,7 @@ export default class Application {
364
364
  return
365
365
  }
366
366
  const currentScrollbarWidth = this.getCurrentScrollBarWidth()
367
- const ev = new window.CustomEvent(Events.APPLICATION_SCROLL_LOCKED, this)
367
+ const ev = new window.CustomEvent(Events.APPLICATION_SCROLL_LOCKED, { detail: this })
368
368
  this._scrollPaddedElements = [document.body, ...extraPaddedElements]
369
369
  window.dispatchEvent(ev)
370
370
  this.SCROLL_LOCKED = true
@@ -379,7 +379,7 @@ export default class Application {
379
379
  if (!this.SCROLL_LOCKED) {
380
380
  return
381
381
  }
382
- const ev = new window.CustomEvent(Events.APPLICATION_SCROLL_RELEASED, this)
382
+ const ev = new window.CustomEvent(Events.APPLICATION_SCROLL_RELEASED, { detail: this })
383
383
  window.dispatchEvent(ev)
384
384
  this.SCROLL_LOCKED = false
385
385
  set(document.body, { overflow: defaultOverflow })
@@ -440,12 +440,18 @@ export default class Application {
440
440
  const duration = time * 1000 // convert to milliseconds
441
441
  const startTime = performance.now()
442
442
 
443
- const easeInOut = t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
443
+ const easingFns = {
444
+ linear: t => t,
445
+ easeIn: t => t * t,
446
+ easeOut: t => t * (2 - t),
447
+ easeInOut: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
448
+ }
449
+ const easeFn = easingFns[ease] || easingFns.easeInOut
444
450
 
445
451
  const animateScroll = currentTime => {
446
452
  const elapsed = currentTime - startTime
447
453
  const progress = Math.min(elapsed / duration, 1)
448
- const eased = easeInOut(progress)
454
+ const eased = easeFn(progress)
449
455
  const currentY = startY + distance * eased
450
456
 
451
457
  window.scrollTo(0, currentY)
@@ -525,10 +531,6 @@ export default class Application {
525
531
  }
526
532
  }
527
533
 
528
- getIOSCurrentInnerHeight() {
529
- return window.innerHeight
530
- }
531
-
532
534
  getIOSInnerHeightMax() {
533
535
  if (!navigator.userAgent.match(/iphone|ipod|ipad/i)) {
534
536
  return window.innerHeight
@@ -597,7 +599,7 @@ export default class Application {
597
599
  }
598
600
 
599
601
  setDims() {
600
- const root = document.querySelector(':root')
602
+ const root = this.root
601
603
 
602
604
  this.size.initialInnerHeight = window.innerHeight
603
605
  this.size.initialOuterHeight = window.outerHeight
@@ -625,13 +627,13 @@ export default class Application {
625
627
  }
626
628
 
627
629
  setFontBaseVw() {
628
- const root = document.querySelector(':root')
630
+ const root = this.root
629
631
  this.size.baseVW = this._getBaseVW()
630
632
  root.style.setProperty('--font-base-vw', `${this.size.baseVW}`)
631
633
  }
632
634
 
633
635
  setZoom() {
634
- const root = document.querySelector(':root')
636
+ const root = this.root
635
637
  root.style.setProperty('--ec-zoom', `${this.size.zoom}`)
636
638
  }
637
639
 
@@ -639,14 +641,14 @@ export default class Application {
639
641
  * Inner height of mobiles may change when showing hiding bottom bar.
640
642
  */
641
643
  setvh100() {
642
- const root = document.querySelector(':root')
644
+ const root = this.root
643
645
  const height = this.featureTests.results.ios ? screen.height : window.innerHeight
644
646
  root.style.setProperty('--vp-100vh', `${height}px`)
645
647
  root.style.setProperty('--vp-1vh', `${height * 0.01}px`)
646
648
  }
647
649
 
648
650
  setvw100() {
649
- const root = document.querySelector(':root')
651
+ const root = this.root
650
652
  root.style.setProperty('--vp-100vw', `${window.innerWidth}px`)
651
653
  root.style.setProperty('--vp-1vw', `${window.innerWidth * 0.01}px`)
652
654
  }
@@ -655,7 +657,7 @@ export default class Application {
655
657
  * Get the max 100vh for iOS
656
658
  */
657
659
  setvh100Max() {
658
- const root = document.querySelector(':root')
660
+ const root = this.root
659
661
  const vh100 = this.featureTests.results.ios
660
662
  ? this.getIOSInnerHeightMax()
661
663
  : this.size.initialInnerHeight
@@ -664,7 +666,7 @@ export default class Application {
664
666
  }
665
667
 
666
668
  setScrollHeight() {
667
- const root = document.querySelector(':root')
669
+ const root = this.root
668
670
  root.style.setProperty('--scroll-h', `${document.body.scrollHeight}px`)
669
671
  }
670
672
 
@@ -702,7 +704,6 @@ export default class Application {
702
704
  */
703
705
  onScroll(e) {
704
706
  if (this.SCROLL_LOCKED) {
705
- e.preventDefault()
706
707
  return
707
708
  }
708
709
 
@@ -737,14 +738,14 @@ export default class Application {
737
738
  }
738
739
 
739
740
  onVisibilityChange(e) {
740
- let evt = new CustomEvent(Events.APPLICATION_VISIBILITY_CHANGE, e)
741
+ let evt = new CustomEvent(Events.APPLICATION_VISIBILITY_CHANGE, { detail: e })
741
742
  window.dispatchEvent(evt)
742
743
 
743
744
  if (document.visibilityState === 'hidden') {
744
- evt = new CustomEvent(Events.APPLICATION_HIDDEN, e)
745
+ evt = new CustomEvent(Events.APPLICATION_HIDDEN, { detail: e })
745
746
  window.dispatchEvent(evt)
746
747
  } else if (document.visibilityState === 'visible') {
747
- evt = new CustomEvent(Events.APPLICATION_VISIBLE, e)
748
+ evt = new CustomEvent(Events.APPLICATION_VISIBLE, { detail: e })
748
749
  window.dispatchEvent(evt)
749
750
  }
750
751
  }
@@ -760,12 +761,13 @@ export default class Application {
760
761
  }
761
762
  }
762
763
 
763
- pollForVar(variable, time = 500, callback = () => {}) {
764
- if (variable !== null) {
765
- callback(variable)
764
+ pollForVar(getter, time = 500, callback = () => {}) {
765
+ const value = getter()
766
+ if (value !== null && value !== undefined) {
767
+ callback(value)
766
768
  } else {
767
769
  setTimeout(() => {
768
- this.pollForVar(variable, time, callback)
770
+ this.pollForVar(getter, time, callback)
769
771
  }, time)
770
772
  }
771
773
  }
@@ -790,8 +792,7 @@ export default class Application {
790
792
 
791
793
  span.addEventListener('click', () => {
792
794
  const copyText = userAgent.querySelector('b')
793
- const textArea = document.createElement('textarea')
794
- textArea.value = `
795
+ const text = `
795
796
  ${copyText.textContent}
796
797
  SCREEN >> ${window.screen.width}x${window.screen.height}
797
798
  WINDOW >> ${windowWidth}x${windowHeight}
@@ -799,10 +800,21 @@ WINDOW >> ${windowWidth}x${windowHeight}
799
800
  FEATURES >>
800
801
  ${JSON.stringify(this.featureTests.results, undefined, 2)}
801
802
  `
802
- document.body.appendChild(textArea)
803
- textArea.select()
804
- document.execCommand('Copy')
805
- textArea.remove()
803
+
804
+ const copyWithFallback = str => {
805
+ if (navigator.clipboard && navigator.clipboard.writeText) {
806
+ return navigator.clipboard.writeText(str)
807
+ }
808
+ const textArea = document.createElement('textarea')
809
+ textArea.value = str
810
+ document.body.appendChild(textArea)
811
+ textArea.select()
812
+ document.execCommand('Copy')
813
+ textArea.remove()
814
+ return Promise.resolve()
815
+ }
816
+
817
+ copyWithFallback(text)
806
818
  span.innerHTML = 'OK!'
807
819
  setTimeout(() => {
808
820
  span.innerHTML = 'KOPIER'
@@ -905,7 +917,7 @@ ${JSON.stringify(this.featureTests.results, undefined, 2)}
905
917
  }
906
918
  }
907
919
  }
908
- document.onkeydown = gridKeyPressed
920
+ document.addEventListener('keydown', gridKeyPressed)
909
921
  }
910
922
 
911
923
  /**
@@ -46,10 +46,20 @@ import DataloaderUrlSync from './url-sync'
46
46
  * <button data-loader-more-for="news">Load more</button>
47
47
  */
48
48
 
49
+ /**
50
+ * @typedef {Object} DataloaderOptions
51
+ * @property {number} page - Starting page index for pagination
52
+ * @property {Object} loaderParam - Initial parameter key/value pairs for API requests
53
+ * @property {string} filter - Initial search filter string
54
+ * @property {number} filterDebounce - Debounce delay in ms for filter input
55
+ * @property {Object|null} urlSync - URL sync config keyed by loader ID
56
+ * @property {function} onFetch - Callback after fetch completes, receives dataloader instance
57
+ */
49
58
  const DEFAULT_OPTIONS = {
50
59
  page: 0,
51
60
  loaderParam: {},
52
61
  filter: '',
62
+ filterDebounce: 650,
53
63
  urlSync: null,
54
64
  onFetch: dataloader => {
55
65
  /**
@@ -91,20 +101,23 @@ export default class Dataloader {
91
101
  this.initialize()
92
102
  }
93
103
 
104
+ /**
105
+ * Replace an element's innerHTML with content fetched from a URL
106
+ *
107
+ * @param {HTMLElement} el - Target element
108
+ * @param {string} url - URL to fetch HTML from
109
+ * @returns {Promise<HTMLElement>} The element with updated content
110
+ */
94
111
  static replaceInnerHTML(el, url) {
95
- return new Promise(resolve => {
96
- fetch(url)
97
- .then(res => {
98
- return res.text()
99
- })
100
- .then(html => {
101
- el.innerHTML = html
102
- return resolve(el)
103
- })
104
- })
112
+ return fetch(url)
113
+ .then(res => res.text())
114
+ .then(html => {
115
+ el.innerHTML = html
116
+ return el
117
+ })
105
118
  }
106
119
 
107
- debounce(func, delay = 650) {
120
+ debounce(func, delay) {
108
121
  let timerId
109
122
  return (...args) => {
110
123
  clearTimeout(timerId)
@@ -136,17 +149,22 @@ export default class Dataloader {
136
149
  initialize() {
137
150
  this.baseURL = this.$el.dataset.loader
138
151
  this.$paramEls = Dom.all(this.$el, '[data-loader-param]')
139
-
152
+
153
+ // Store bound handlers for cleanup in destroy()
154
+ this._boundOnParam = this.onParam.bind(this)
155
+ this._boundOnMore = this.onMore.bind(this)
156
+ this._boundOnFilter = this.debounce(this.onFilterInput.bind(this), this.opts.filterDebounce)
157
+
140
158
  // Initialize URL sync if config exists for this dataloader ID
141
159
  if (this.opts.urlSync?.[this.id]) {
142
160
  this.urlSync = new DataloaderUrlSync(this, this.opts.urlSync[this.id])
143
161
  }
144
-
162
+
145
163
  // Set initial parameters from pre-selected elements
146
164
  this.setInitialParams()
147
165
 
148
166
  this.$paramEls.forEach($paramEl => {
149
- $paramEl.addEventListener('click', this.onParam.bind(this))
167
+ $paramEl.addEventListener('click', this._boundOnParam)
150
168
  })
151
169
 
152
170
  this.$moreBtn = Dom.find(this.$el, '[data-loader-more]')
@@ -156,7 +174,7 @@ export default class Dataloader {
156
174
  }
157
175
 
158
176
  if (this.$moreBtn) {
159
- this.$moreBtn.addEventListener('click', this.onMore.bind(this))
177
+ this.$moreBtn.addEventListener('click', this._boundOnMore)
160
178
  }
161
179
 
162
180
  this.$filterInput = Dom.find(this.$el, '[data-loader-filter]')
@@ -166,7 +184,7 @@ export default class Dataloader {
166
184
  }
167
185
 
168
186
  if (this.$filterInput) {
169
- this.$filterInput.addEventListener('input', this.debounce(this.onFilterInput.bind(this)))
187
+ this.$filterInput.addEventListener('input', this._boundOnFilter)
170
188
  }
171
189
  }
172
190
 
@@ -185,54 +203,69 @@ export default class Dataloader {
185
203
  this.fetch(true)
186
204
  }
187
205
 
206
+ getParamKey(el) {
207
+ return el.dataset.loaderParamKey || 'defaultParam'
208
+ }
209
+
210
+ handleCheckboxParam(el) {
211
+ const key = this.getParamKey(el)
212
+ this.opts.loaderParam[key] = el.checked
213
+ }
214
+
215
+ handleDeselectParam(el, multiVals) {
216
+ const key = this.getParamKey(el)
217
+ if (multiVals) {
218
+ this.opts.loaderParam[key] = this.opts.loaderParam[key].filter(val => {
219
+ return val !== el.dataset.loaderParam
220
+ })
221
+ } else {
222
+ delete this.opts.loaderParam[key]
223
+ }
224
+ el.removeAttribute('data-loader-param-selected')
225
+ }
226
+
227
+ handleMultiSelectParam(el) {
228
+ const key = this.getParamKey(el)
229
+ if (!Object.hasOwn(this.opts.loaderParam, key)) {
230
+ this.opts.loaderParam[key] = []
231
+ }
232
+ this.opts.loaderParam[key].push(el.dataset.loaderParam)
233
+ el.setAttribute('data-loader-param-selected', '')
234
+ }
235
+
236
+ handleSingleSelectParam(el) {
237
+ const paramKey = el.dataset.loaderParamKey
238
+ this.$paramEls.forEach($paramEl => {
239
+ if (paramKey) {
240
+ if ($paramEl.dataset.loaderParamKey === paramKey) {
241
+ $paramEl.removeAttribute('data-loader-param-selected')
242
+ }
243
+ } else {
244
+ $paramEl.removeAttribute('data-loader-param-selected')
245
+ }
246
+ })
247
+ el.setAttribute('data-loader-param-selected', '')
248
+ const key = this.getParamKey(el)
249
+ this.opts.loaderParam[key] = el.dataset.loaderParam
250
+ }
251
+
188
252
  onParam(e) {
189
253
  this.loading()
190
- // reset page when switching param!
191
254
  this.opts.page = 0
192
255
 
193
- // param can have multiple values
194
- const multiVals = e.currentTarget.hasAttribute('data-loader-param-multi')
256
+ const el = e.currentTarget
257
+ const multiVals = el.hasAttribute('data-loader-param-multi')
195
258
 
196
- // special case if it's a checkbox!
197
- if (e.currentTarget.getAttribute('type') === 'checkbox') {
198
- const key = e.currentTarget.dataset.loaderParamKey || 'defaultParam'
199
- this.opts.loaderParam[key] = e.currentTarget.checked
259
+ if (el.getAttribute('type') === 'checkbox') {
260
+ this.handleCheckboxParam(el)
200
261
  } else {
201
262
  e.preventDefault()
202
- if (e.currentTarget.hasAttribute('data-loader-param-selected')) {
203
- // if already selected, clear it
204
- const key = e.currentTarget.dataset.loaderParamKey || 'defaultParam'
205
- if (multiVals) {
206
- this.opts.loaderParam[key] = this.opts.loaderParam[key].filter(val => {
207
- return val !== e.currentTarget.dataset.loaderParam
208
- })
209
- } else {
210
- delete this.opts.loaderParam[key]
211
- }
212
- e.currentTarget.removeAttribute('data-loader-param-selected')
263
+ if (el.hasAttribute('data-loader-param-selected')) {
264
+ this.handleDeselectParam(el, multiVals)
265
+ } else if (multiVals) {
266
+ this.handleMultiSelectParam(el)
213
267
  } else {
214
- if (multiVals) {
215
- const key = e.currentTarget.dataset.loaderParamKey || 'defaultParam'
216
- if (!this.opts.loaderParam.hasOwnProperty(key)) {
217
- this.opts.loaderParam[key] = []
218
- }
219
- this.opts.loaderParam[key].push(e.currentTarget.dataset.loaderParam)
220
- e.currentTarget.setAttribute('data-loader-param-selected', '')
221
- } else {
222
- const paramKey = e.currentTarget.dataset.loaderParamKey
223
- this.$paramEls.forEach($paramEl => {
224
- if (paramKey) {
225
- if ($paramEl.dataset.loaderParamKey === paramKey) {
226
- $paramEl.removeAttribute('data-loader-param-selected')
227
- }
228
- } else {
229
- $paramEl.removeAttribute('data-loader-param-selected')
230
- }
231
- })
232
- e.currentTarget.setAttribute('data-loader-param-selected', '')
233
- const key = e.currentTarget.dataset.loaderParamKey || 'defaultParam'
234
- this.opts.loaderParam[key] = e.currentTarget.dataset.loaderParam
235
- }
268
+ this.handleSingleSelectParam(el)
236
269
  }
237
270
  }
238
271
 
@@ -245,13 +278,19 @@ export default class Dataloader {
245
278
  }
246
279
 
247
280
  fetch(addEntries = false) {
281
+ // Cancel any in-flight request to prevent race conditions
282
+ if (this._abortController) {
283
+ this._abortController.abort()
284
+ }
285
+ this._abortController = new AbortController()
286
+
248
287
  const { defaultParam, ...otherParams } = this.opts.loaderParam
249
288
  const filter = this.opts.filter
250
-
289
+
251
290
  const fetchUrl = `${this.baseURL}/${defaultParam ? defaultParam + '/' : ''}${this.opts.page}?` +
252
291
  new URLSearchParams({ filter, ...otherParams })
253
292
 
254
- fetch(fetchUrl)
293
+ fetch(fetchUrl, { signal: this._abortController.signal })
255
294
  .then(res => {
256
295
  this.status = res.headers.get('jpt-dataloader') || 'available'
257
296
  this.updateButton()
@@ -259,13 +298,18 @@ export default class Dataloader {
259
298
  })
260
299
  .then(html => {
261
300
  if (addEntries) {
262
- this.$canvasEl.innerHTML += html
301
+ this.$canvasEl.insertAdjacentHTML('beforeend', html)
263
302
  } else {
264
303
  this.$canvasEl.innerHTML = html
265
304
  }
266
305
  this.opts.onFetch(this)
267
306
  this.complete()
268
307
  })
308
+ .catch(err => {
309
+ if (err.name === 'AbortError') return
310
+ console.error(`Dataloader[${this.id}] fetch error:`, err)
311
+ this.complete()
312
+ })
269
313
  }
270
314
 
271
315
  /**
@@ -300,4 +344,37 @@ export default class Dataloader {
300
344
  this.$moreBtn.removeAttribute('data-loader-starved')
301
345
  }
302
346
  }
347
+
348
+ /**
349
+ * Remove all event listeners and clean up resources
350
+ */
351
+ destroy() {
352
+ // Abort any in-flight fetch
353
+ if (this._abortController) {
354
+ this._abortController.abort()
355
+ }
356
+
357
+ // Remove param listeners
358
+ this.$paramEls.forEach($paramEl => {
359
+ $paramEl.removeEventListener('click', this._boundOnParam)
360
+ })
361
+
362
+ // Remove more button listener
363
+ if (this.$moreBtn) {
364
+ this.$moreBtn.removeEventListener('click', this._boundOnMore)
365
+ }
366
+
367
+ // Remove filter input listener
368
+ if (this.$filterInput) {
369
+ this.$filterInput.removeEventListener('input', this._boundOnFilter)
370
+ }
371
+
372
+ // Clean up URL sync
373
+ if (this.urlSync) {
374
+ this.urlSync.destroy()
375
+ }
376
+
377
+ // Remove loading state
378
+ this.complete()
379
+ }
303
380
  }
@@ -9,6 +9,7 @@ export default class FeatureTests {
9
9
  }
10
10
 
11
11
  this.results = {}
12
+ this.deviceLastTouched = 0
12
13
 
13
14
  if (this.testIE11()) {
14
15
  this.testFor('ie11', true)
@@ -118,7 +119,7 @@ export default class FeatureTests {
118
119
 
119
120
  const onMouseMove = () => {
120
121
  if (!this.results.mouse) {
121
- if (Date.now() - this.devicelastTouched > 300) {
122
+ if (Date.now() - this.deviceLastTouched > 300) {
122
123
  this.results.touch = false
123
124
  this.results.mouse = true
124
125
  this.testFor('touch', false)