diff --git a/.eslintrc.json b/.eslintrc.json index 4281673..577b0d7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -70,7 +70,7 @@ "error", "unix" ], - "lines-around-comment": "error", + "lines-around-comment": "off", "lines-around-directive": "error", "max-depth": "off", "max-len": "off", diff --git a/modules/active.js b/modules/active.js index 5d1859c..0408237 100644 --- a/modules/active.js +++ b/modules/active.js @@ -6,7 +6,6 @@ scroll-snap alignment. import { px } from '../../dom/modules/parse-length.js'; import rect from '../../dom/modules/rect.js'; -import { trigger } from '../../dom/modules/trigger.js'; function getPaddedBox(scroller) { @@ -55,21 +54,23 @@ function scrollToTarget(scroller, target, behavior) { export function scrollTo(scroller, target) { scrollToTarget(scroller, target, 'smooth'); + return target; } export function jumpTo(scroller, target) { scroller.style.setProperty('scroll-behavior', 'auto', 'important'); scrollToTarget(scroller, target, 'auto'); scroller.style.setProperty('scroll-behavior', ''); + return target; } -function getActive(scroller, children) { +function getAligned(scroller, elements) { const { leftPadding, rightPadding, centrePadding } = getPaddedBox(scroller); - let n = children.length; + let n = elements.length; let slide; - while ((slide = children[--n])) { + while ((slide = elements[--n])) { const slideRect = rect(slide); if (!slideRect) { continue; } @@ -105,14 +106,17 @@ function isGhost(slide) { return !!slide.dataset.slideIndex; } -/** -'slide-active' -Emitted by a slide when it is brought into scroll-snap alignment. -**/ +export function getActive(data) { + const { scroller, elements, children } = data; + const aligned = getAligned(scroller, elements); + return isGhost(aligned) ? + children[aligned.dataset.slideIndex] : + aligned ; +} export function updateActive(data) { const { scroller, children, elements } = data; - const current = getActive(scroller, elements); + const current = getAligned(scroller, elements); let active; // If current is a loop ghost jump to the actual slide it references @@ -125,9 +129,5 @@ export function updateActive(data) { active = current; } - if (active === data.active) { return; } - data.active = active; - if (active === undefined) { return; } - data.actives.push(active); - trigger('slide-active', active); + data.aaa.push(active); } diff --git a/modules/lifecycle.js b/modules/lifecycle.js index aa95522..6a9f1ce 100644 --- a/modules/lifecycle.js +++ b/modules/lifecycle.js @@ -1,6 +1,5 @@ import equals from '../../fn/modules/equals.js'; -import noop from '../../fn/modules/noop.js'; import nothing from '../../fn/modules/nothing.js'; import Stream from '../../fn/modules/stream.js'; import create from '../../dom/modules/create.js'; @@ -8,11 +7,14 @@ import events, { isPrimaryButton } from '../../dom/modules/events.js'; import gestures from '../../dom/modules/gestures.js'; import { px } from '../../dom/modules/parse-length.js'; import rect from '../../dom/modules/rect.js'; -import scrollStreams from '../../dom/modules/scrolls.js'; -import { $data } from './consts.js'; +import { $data } from './consts.js'; import { scrollTo, jumpTo, updateActive } from './active.js'; import { processPointers } from './swipes.js'; +import scrollends from './scrollends.js'; + + +import { trigger } from '../../dom/modules/trigger.js'; function getWidth(scroller, slides, children) { @@ -46,6 +48,11 @@ function isSlide(slide) { /* Lifecycle */ +/** +'slide-active' +Emitted by a slide when it is brought into scroll-snap alignment. +**/ + export default { construct: function(shadow) { // Shadow DOM @@ -56,19 +63,17 @@ export default { // Add slots to shadow shadow.append(scroller, controls); - // Create streams from things that happen to slide-show + // Stream to push load to const load = Stream.of(); // In Chrome and FF initial `slotchange` event is always sent before - // load, but not so in Safari where either order may happen, at a guess - // due to some caching strategy. Here we sanitise order by making - // slotchanges stream dependent on load. There's little point in doing - // much before load anyway, as active slide is dependent on loaded - // stylesheet. + // load, but not so in Safari where either order may happen (at a guess + // due to some caching strategy). Here we sanitise order by making + // slotchanges stream always fire after load. const slotchanges = Stream .combine({ host: load, - elements: events({ type: 'slotchange', select: '[part="slides"]' }, slides) + elements: events('slotchange', slides) .map((e) => data.elements = slides.assignedElements()), }) .broadcast({ memory: true }); @@ -82,6 +87,22 @@ export default { }) .broadcast({ memory: true }); + // Buffer stream for pushing children to scroll to and activate + const activates = Stream.of(null); + + // Buffer stream for pushing children to activate + const aaa = Stream.of(); + + // Broadcast stream for listen to changes to active + const actives = aaa + .filter((child) => (data.active !== child && trigger('slide-active', child))) + .map((child) => data.active = child) + .broadcast({ memory: true }); + + const focuses = events('focusin', this); + const resizes = events('resize', window); + const fullscreens = events('fullscreenchange', window); + const clicks = events('click', shadow) .filter(isPrimaryButton) .broadcast(); @@ -89,13 +110,6 @@ export default { const swipes = gestures({ threshold: '0.25rem', device: 'mouse' }, shadow) .filter(() => data.children.length > 1); - const scrolls = scrollStreams(scroller); - const focuses = events('focusin', this); - const resizes = events('resize', window); - const fullscreens = events('fullscreenchange', window); - const activates = Stream.of(); - const actives = Stream.broadcast({ memory: true }); - // Private data const data = this[$data] = { clickSuppressTime: -Infinity, @@ -109,6 +123,7 @@ export default { controls, load, activates, + aaa, actives, slotchanges, mutations, @@ -118,17 +133,39 @@ export default { // Create a stream of width updates Stream .merge(slotchanges, resizes) - .each(() => updateWidth(data.scroller, data.slides, data.elements)); + .each((e) => updateWidth(scroller, slides, data.elements)); - // let count = 0; + // Wait for fist slotchange/load, then on mutation maintain active + // position, or on activation scroll to new child, then pipe the child + // to be activated Stream - .combine({ changes: slotchanges, child: activates }) - .each((state) => (data.loaded ? - scrollTo(data.scroller, state.child) : - jumpTo(data.scroller, state.child) - )); + .combine({ children: mutations, child: activates }) + .map((state) => { + // Is previous child not yet defined, or the new one the same as it? + if (!data.active || data.active === state.child) { + // This is a mutation, so jump to the active child if it is + // still there, or the first child if not + return jumpTo(scroller, state.children.includes(state.child) ? + state.child : + state.children[0] + ); + } + + // This is an activation, scroll to the new active child without + // a check for being in children, we may be activating a ghost + scrollTo(scroller, state.child); - slotchanges.each((state) => updateActive(data)); + // If it is a ghost, activate the original not the ghost + return state.child.dataset.slideIndex ? + state.children[state.child.dataset.slideIndex] : + state.child ; + }) + .pipe(aaa); + + // Update active when scroll comes to rest, but not mid-gesture + scrollends(scroller) + .filter(() => !data.gesturing) + .each((stream) => updateActive(data)); // Prevent default on immediate clicks after a gesture, and don't let // them out: this is a gesture not a click @@ -158,13 +195,6 @@ export default { } }); - // Update active when scroll comes to rest - scrolls - .each((stream) => stream - .each(noop) - .done(() => updateActive(data)) - ); - // Chrome behaves nicely when shifting focus between slides, Safari and // FF not so much. Let's give them a helping hand at displaying the // focused slide. Todo: FF not getting this. diff --git a/modules/properties.js b/modules/properties.js index 90347a2..2bebe5b 100644 --- a/modules/properties.js +++ b/modules/properties.js @@ -14,9 +14,15 @@ import * as fullscreen from './fullscreen.js'; /* Properties */ export default { + /** + active="#id" + Sets the initially scroll-snapped slide. `#id` must be the id of a direct + child of the ``. + **/ + /** .active - Gets or sets the currently scroll-snapped child element. + Gets or sets the currently scroll-snapped slide. ```js const activeSlide = slideshow.active; @@ -41,10 +47,11 @@ export default { // Accept an id const child = typeof id === 'object' ? id : - this.querySelector('#' + (/^\d/.test((id + '')[0]) ? - '\\3' + (id + '')[0] + ' ' + (id + '').slice(1) : - id) - ) ; + /^\d/.test(id + '') ? + this.querySelector('#\\3' + (id + '')[0] + ' ' + (id + '').slice(1)) : + /^\#/.test(id + '') ? + this.querySelector(id) : + this.querySelector('#' + id) ; if (!child) { throw new Error('Cannot set active – not a child of slide-show'); diff --git a/modules/scrollends.js b/modules/scrollends.js new file mode 100644 index 0000000..2978f6b --- /dev/null +++ b/modules/scrollends.js @@ -0,0 +1,84 @@ + +// Much of this code has been purloined from targetable.js – do we need the +// hashchange tracking here? I have commented it + +import Stream from '../../fn/modules/stream.js'; +import Producer from '../../fn/modules/stream/producer.js'; + +const assign = Object.assign; + +// Capture scroll events in capture phase, as scroll events from elements +// other than document do not bubble. +const captureOptions = { + capture: true, + passive: true +}; + +const config = { + minScrollEventInterval: 0.0375, + maxScrollEventInterval: 0.18 +}; + +var trackingInterval = config.maxScrollEventInterval; + +function adjustTrackingInterval(times) { + // Dynamically adjust maxScrollEventInterval to tighten it up, + // imposing a baseline of 60ms (0.0375s * 1.6) + + let n = times.length; + let interval = 0; + + while (--n) { + const t = times[n] - times[n - 1]; + interval = t > interval ? t : interval; + } + + interval = interval < config.minScrollEventInterval ? + config.minScrollEventInterval : + interval ; + + trackingInterval = (1.4 * interval) > config.maxScrollEventInterval ? + config.maxScrollEventInterval : + (1.4 * interval) ; +} + +function fire(producer, e) { + producer.timer = undefined; + producer.stream.push(e); + + const times = producer.times; + if (times.length > 1) { adjustTrackingInterval(times); } + times.length = 0; +} + +function ScrollendsProducer(element) { + this.element = element; + this.times = []; +} + +assign(ScrollendsProducer.prototype, Producer.prototype, { + pipe: function(stream) { + this.stream = stream; + this.element.addEventListener('scroll', this, captureOptions); + }, + + handleEvent: function(e) { + const time = e.timeStamp / 1000; + this.times.push(time); + + if (this.timer) { + clearTimeout(this.timer); + } + + this.timer = setTimeout(fire, trackingInterval * 1000, this, e); + }, + + stop: function() { + this.element.removeEventListener('scroll', this); + Producer.prototype.stop.apply(this, arguments); + } +}); + +export default function scrollends(element) { + return new Stream(new ScrollendsProducer(element)); +} diff --git a/modules/swipes.js b/modules/swipes.js index e691b67..9cd0af1 100644 --- a/modules/swipes.js +++ b/modules/swipes.js @@ -2,7 +2,7 @@ import events from '../../dom/modules/events.js'; import overload from '../../fn/modules/overload.js'; -import { updateActive } from './active.js'; +import { getActive } from './active.js'; export const processPointers = overload((data, e) => e.type, { pointerdown: function(data, e) { @@ -68,7 +68,11 @@ export const processPointers = overload((data, e) => e.type, { // We may as well preemptively update the active slide now, since we // are sitting in the new position. This is not a crucial step, just // makes the UI react a bit more quickly. - updateActive(data); + // Dont, actually, if there are ghosts this causes some jumping + // around. + //updateActive(data); + const active = getActive(data); + data.aaa.push(active); // Otherwise we have to do things the hard way. Switch scroll-snap // off again and put scroll back to position 1, ... diff --git a/package.json b/package.json index a070fba..d2c3b8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "title": "slide-show", - "version": "1.0.0", + "version": "1.1.0", "description": "The accessible, scrollable, styleable, horizontal carousel custom element. No dependencies, about 12kB minified and gzipped.", "author": { "name": "Stephen Band", diff --git a/test/2-slide-show.html b/test/2-slide-show.html index 8317cce..d8ebd6d 100644 --- a/test/2-slide-show.html +++ b/test/2-slide-show.html @@ -71,9 +71,19 @@ -
<slide-show loop autoplay controls="navigation pagination fullscreen"> - 5 children
+
<slide-show loop controls="navigation pagination fullscreen"> - 5 children
- + + + + + + + + +
<slide-show loop autoplay controls="navigation pagination fullscreen"> - one child
+ + @@ -81,8 +91,6 @@ -
<slide-show loop autoplay controls="navigation pagination fullscreen"> - one child
- Explore Buy @@ -92,14 +100,6 @@ Contact us - - - - - - - -
slide-show       { grid-auto-columns: 60%; }
 slide-show > img { scroll-snap-align: center; }
@@ -145,12 +145,10 @@ -