More Slide-y Thinking

More Slide-y Thinking

Continuing with the same higher-order functions and first principles to make minimal slide-y sliders in vanilla JS

Let's pick it up right where we left off last time.

A Minimal Requirement Analysis

We might need to have a lot more images in a slideshow. We will have to find ways to keep movements smooth using the appropriate CSS for the transitions and/or animations.

In a more complex and publicly usable slider, what else would be required of the slider HTML components and the JS/CSS backing them?

Let's start with some laziness this time. Laziness is usually earned with hard smart work though.

In the context of a slider that loads many images into a browser tab, one would prefer to not load everything at once as that would require a longer wait before things are first painted, made visible, and added with interactivity. If we have a way to check what is needed to be currently displayed, we could fetch or pre-fetch those images first or just those images exclusively.

Bounds, aka How To Get Rect

It was a good idea to lookup getBoundingClientRect() on MDN as I had not been on MDN in recent months.

For a naive first implementation, seems like you can easily compare the rectangular dimensions of the boundary of DOM element you want to check against the height or width of the window or that of the document.documentElement.

function isInViewport(element) {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

Here, given a DOM element, we check the top, left, bottom and right corner's coordinates and see if it falls inside the document or the window in which the slider slides.

We can now use this kind of check, or something more elaborate or reactive, to decide whether to load an image into our slider container. Here's an example of how we used to load images lazily with this strategy.


/**
 * @example window.addEventListener('scroll', lazyLoad);
 * */ 
function lazyLoad() {
  const lazyImages = document.querySelectorAll('.slidey-img[data-src]');

  lazyImages.forEach(img => {
    if (isInViewport(img)) {
      img.src = img.dataset.src;
      img.removeAttribute('data-src');
    }
  });
}

The secret to behind the scenes lazy loading of images is just this ^. We basically just create the img element and/or update the src URL. It is a common pattern to co-locate a DOM node-specific data, like the image node's tag, under a data-* style attribute in that same node. In this case, we keep img elements in our HTML with each of their src paths (e.g. img/public/square/brain.png) under data-src instead of src. We put that data-src's value into the src not eagerly before the image is even needed by the UI.

How lazily, or early should we fetch images for the viewport? There are differing viewpoints on this.

Whether we pre-fetch or even cache pre-fetched items for long is purely dependent on the use cases we are catering to. It makes sense to eagerly load perhaps the first prev, center and next images. However, if we don't time our pre-fetching or delayed-fetching of images right in reactive sense, we will be exposing our audience to a lag, layout shifts and jitter-based user experience.

Reactivity primitives like the observable pattern espoused for reactive and dynamic workloads aim to solve this general class of problems too. Nevertheless, before the eponymous tc-39 proposal, we already have a working implementation of an observer API already.

Here's an almost canonical example, from MDN, of how one would determine whether a slider-div or any other element is coming into view or is currently viewable.

const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  threshold: 1.0,
};

const observer = new IntersectionObserver(callback, options);

N.B. Although it is not emphasized much, there is never really a need to use let in production code other than for setting up test beds with a closure state like the one above for an efficient and simplified state management with adequate separation of concerns. My last production code that actually needed let or mutation was at least a decade ago.

Using this callback function, we also achieve what is known as the Hollywood Principle in the business. Whatever it is that needs to be done by the event handler e.g. using lazy loader, pre-fetcher and/or next/prev functions when the root element of the document is visible can be done when we get a call back from the Hollywood Observer.

As actors, event handlers or script-writers, we don't need to be the one calling. We are supposed to be the ones receiving the call when the moment arises. We may be or need to be at different intersections. Just as we have characters in our scripts, we might ourselves need to be actors or handlers in a story or event other than ourselves.

A Bit of Meta

Note that we can still use our isInViewport in combination with the above intersection observer callback depending on our implementation logic and intuitions. In fact, it would be preferable if our final implementation builds up towards being able to reactively observe the slides while simultaneously designing the slides to be observed by a real-life alive user.

As programmers, and noticeably not yet professional developers, we might jump into coding sessions with a linear imperative thinking in mind or with other biases like trying to model everything as either just objects or just functions. In real-world applications, it is not about the principles or paradigms of programming or computing or any architecture over the other. Software and slidey things running out there in the wild, at the edge of space, oceanic, biological or commercial settings, whether all of my own in each case, or of others who wrote after having built planet scale projects, takes practical considerations and unforeseeable optimizations and indirection all the time over any philosophy.

As I had tried to start writing about Scala and how objects and functions can together work in mission critical systems and how we can build up towards formally verified software, I had to take an abrupt pause. COVID hit, but also I realized that not all my projects are going to be about some civilization concerning consortium projects all the time. The same in-depth, meditative, and highly-observant craftsmanship and ruthless pragmatism to solve bigger problems needs to be applied in things like sliding sliders or little buttons that make things happen. In the details lie the essence and in the essence emerge the details.

So, let's continue to work on smaller things, slidey things and just as with the calculus of constructions, we may build towards bigger holons. H or theory-holons are complex theory-nets tied by “essential” links in this context. To keep things simple, let's focus back on 'essentials' of a slider's physics and design.

A Short Keynote: Bored with Navigation Rodents

Do we have any agents other than the slider system itself as a whole? Users are the obvious elephants in the room and at least some of them, including me, like to operate our machines with just our keyboards. Just assume that we missed the whole memo on why mice are nice from a long forgotten but venerable Mother of All Demos. This is all we ask for as ancient keyboard users who are still not finding the right click or left tap-dancing on our screens:

/**
 * @example document.addEventListener('keydown', handleKeyboardNavigation);
*/
function handleKeyboardNavigation(event) {
  switch (event.key) {
    case 'ArrowLeft':
      prev();
      break;
    case 'ArrowRight':
      next();
      break;
    // Maybe more cases for other navigation keys if needed
    default:
      break;
  }
}

Getting our S#@% Together

In our simplified example, the sliderContext had only one mutable state and one const reference to the slider element in our HTML. However, if we need a real-life, production-ready and/or general purpose implementation, we would not have just some values or objects or references but a whole class of it. Literally:


const defaultOptions = {
  auto: true,
  infinite: true,
  time: 2000,
  forward: true, // Can't we just keep `prev`ing instead of `next`ing?
  sibling: 'slidey-sib' // Can we sync with another slider on-screen?
}

class SliderState {

  constructor(containerSelector, options = defaultOptions) {
    this.ticking = false;
    this.container = document.querySelector(
      ensureSingleDotPrefix(containerSelector)
    );
    this.options = { ...defaultOptions, ...options };
    this.currentIndex = 0;
    this.images = Array.from(this.container.querySelectorAll('.slidey-img'));
    this.dots = Array.from(this.container.querySelectorAll('.slidey-dot'));
    this.prevButton = this.container.querySelector('.prev');
    this.nextButton = this.container.querySelector('.next');
    this.imagesLength = this.images?.length ?? 17; // magic number. Deal with it :sunglasses:
  }
}

Here we have a more ambitious slider which aims to be able to provide a few more options and handles references to the current state of the slider in memory and in HTML/CSS in a single class that just holds the state of the slider. this is very hard to understand and is polymorphic and call-site dependent. So we create a class or a higher order function with closure to collect all of these objects, indexes, values, and references. In TypeScript, or an OOP supporting language like Scala or even PHP, you could protect access to these states using private, protected or public access modifiers which encapsulate the logic to consistently update the state variables in a guarded manner - with better separation of concerns regarding state management or value-change-tracking.

We can have either a companion function or object which provides all the operations that are possible on the above slider with the given state of its slides. In the functional version, we can basically swap our earlier closure with let, where we already had our individual mutating index state, with a single state class const.

Note that the const does not mean immutable values but immutable reference in JS. Subtle difference. Also, this class may effectively behave like our own state-store after some embellishments.

class SliderState {

  constructor(containerSelector, options = defaultOptions) {
    // same as above
  }
}

const sliderOps = (containerSelector, options = defaultOptions) => {

  const state = new SliderState(containerSelector, options);

  // all the functions that need access to this state should be kept within this enclosure....
}

The State of Slidey Things

So how do we now update the pagination dots that show which of the n-images of our slider we are viewing?

const updateDots = (index) => {
  state.dots.forEach((dot, dotIndex) => {
    if (dotIndex === index) {
      dot.classList.add('active');
    } else {
      dot.classList.remove('active');
    }
  });
}

In the vanilla JS world, and sometimes even in complex-but-light libraries (like Preact), frameworks (like Angular/Analog), meta-frameworks (like Astro) and languages (like TS+Svelte, Reason, ScalaJS), a common way to talk to the DOM is to add or remove or toggle the CSS class or other DOM attributes. This is usually done by fetching an explicit or implicit reference to the DOM element themselves or at least functions, actors, functors, applicatives, monads, transformers and/or algebraic effect systems.

It is also common to add or remove event listeners that react to a particular event, whenever the event (e.g. click or resize) happens. It would be a good idea to do such attachments in one place - adhering to the single responsibility principle, in some sense.

const addListeners = () => {

  state.dots.forEach((dot, index) => {
    dot.addEventListener('click', () => jumpTo(index));
  });

  state.prevButton.addEventListener('click', () => prev());
  state.nextButton.addEventListener('click', () => next());
}

Something like centering the image is a bit non-trivial but can be achieved with a logic like this one:

// utils can live outside of `sliderContext` or its `sliderOps` module
function isValidIndex(index, length) {
  return index >= 0 && index < length;
}

// this is one of the ops inside `sliderOps`
const centerImage = (index) => {
  if (!isValidIndex(index, state.imagesLength)) return;

  const currentImage = state.images[index];
  const containerHalfWidth = state.container.offsetWidth / 2;
  const imageHalfWidth = currentImage.offsetWidth / 2;

  let totalPrevWidth = 0;
  for (let i = 0; i < index; i++) {
    totalPrevWidth += state.images[i].offsetWidth;
  }

  const offsetToCenter = containerHalfWidth - totalPrevWidth - imageHalfWidth;
  currentImage.parentNode.style.transform = `translateX(${offsetToCenter}px)`;
  currentImage.parentNode.style.transition = 'transform 0.9s ease-in-out';
}

The rest of the CSS is left as an exercise to the reader. This author is not qualified enough to talk CSS and has barely put together one non-trivial CSS file recently for a feature-rich but code-golf-level minimal slider.

When can we use centerImage? Sometimes it is best to keep track of the duration within which our functors need to funk or our methods/ops need to op. To keep things minimal and simple, this is how we achieve this:

function updateSlider() {
  if (!state.ticking) {
    requestAnimationFrame(updateSliderPosition);
    state.ticking = true;
  }
}

function updateSliderPosition() {
  centerImage(state.currentIndex);
  state.ticking = false;
}

The ticking state is just a flag to keep track of one tick or one step or unit of movement of the cogs in our machinery. Now we can stay assured that our slider is being updated/centered in our div or viewport in the browser's own pace and cycles of animations or frame updates.

Note that in the JS world, especially with a browser, node or bun style runtime, we think in terms of a single threaded event loop by default. Every task, micro-task, event-dispatch, animation or state update happens somewhere within this singular event-loop. Instead of jumping into the loop, we should think about submitting heavier tasks humbly to the browser's/runtime's own mechanisms and native ways of incorporating new items into the event loop. We should try not to keep the main UI thread busy or interrupted every time we want something to happen e.g. an update of a slide. We should politely/declaratively ask the browser to do it instead of imperatively imposing our linear order/steps into the loop.

All of this does not mutate our modulo-arithmetic based slide circulation logic from before. It just adds a new step to it, which is actually just an embellished version of the last logic for the same:

const next = () => {
  state.currentIndex = (state.currentIndex + 1) % state.imagesLength;
  updateDots(state.currentIndex);
  updateSlider();
}

const prev = () => {
  state.currentIndex = (state.currentIndex - 1 + state.imagesLength) % state.imagesLength;
  updateDots(state.currentIndex);
  updateSlider();
}

const jumpTo = (index) => {
  if (!isValidIndex(index, state.imagesLength)) return;
  state.currentIndex = index;
  updateDots(state.currentIndex);
  updateSlider();
}

Apart from 'click' or keyboard navigation or pre-defined temporally scheduled loops, we may also get another form of user interaction: the resizing of the container or tab or window within which the slider resides.

Here's a simple way to do that. YMMV:

const handleResize = () => {
  const maxHeight = window.innerHeight * 0.8;
  const maxWidth = window.innerWidth * 0.9;
  state.container.style.maxHeight = `${maxHeight}px`;
  state.container.style.maxWidth = `${maxWidth}px`;
}

A No Cap Recap

A function usually returns something but in the JS world we do not operate with that guarantee. A function may need to be impure in the sense that it manipulates some DOM node or state somewhere or even simply have a side effect like logging to the console or writing to some DB somewhere else. In such cases, it would be good to have the name of the function and its signature/documentation itself should somehow tell us what side effects are going to happen. In our case, we would prefer to have initialized the slider before returning the handlers for next, prev, jumpTo for use by our slidey-slider library's users.

This is essentially all that a slider does to become slidey:

class SliderState {

  constructor(containerSelector, options = defaultOptions) {
    // same as above
  }
}

const sliderOps = (containerSelector, options = defaultOptions) => {

  const state = new SliderState(containerSelector, options);

  // the rest of the owl, as described above
  // ... 

  // initialization routine:
  addListeners();
  updateDots(state.currentIndex);
  centerImage(state.currentIndex);
  handleResize();
  window.addEventListener('resize', handleResize); // some listeners may be added to the window or the document in that window itself, instead of a DOM node.
  initLoop(); // the previously described logic for automatically looping the slidey-slides

  return {
    next,
    prev,
    jumpTo
  }
}

Where do we slide next from here?