Exiros

Things I learned working in a recycled website

Hey!

I'm Agus

  • πŸ’… I like most working in UI
  • πŸ‘©β€πŸ­ I've been working here for a year and a half
  • πŸ‘Ώ TS is my nemesis
  • πŸ™ˆ I get reaaally nervous when I have to talk to a lot of people

Things we did different

(and I liked them πŸ’…)

Hero Slider πŸš€

The before πŸ˜…

import AbstractTransitionBlock from 'app/component/block/AbstractTransitionBlock';
import LandingHeroTransitionController from './LandingHeroTransitionController';
import formatNumber from '../../../util/formatNumber';
export default class LandingHero extends AbstractTransitionBlock {
  public static displayName: string = 'landing-hero';
  public transitionController: LandingHeroTransitionController;
  private readonly wrapper: HTMLDivElement;
  private readonly list: HTMLUListElement;
  private readonly listItems: Array<HTMLLIElement>;
  private readonly images: Array<HTMLLIElement>;
  private readonly prevButton: HTMLButtonElement;
  private readonly nextButton: HTMLButtonElement;
  private readonly currentCopy: HTMLParagraphElement;
  private readonly progressLine: HTMLSpanElement;
  private currentSlide: number = 0;
  private swipeDirection: number = 0;
  private posX: number = 0;
  private posY: number = 0;
  private isSwiping: boolean = false;
  private windowWidth: number = window.innerWidth;
  constructor(el: HTMLElement) {
    super(el);
    this.transitionController = new LandingHeroTransitionController(this);
    this.wrapper = this.getElement('[data-hero-wrapper]');
    this.list = this.getElement('[data-hero-content-list]');
    this.listItems = this.getElements('[data-hero-content-item]');
    this.images = this.getElements('[data-hero-image]');
    this.prevButton = this.getElement('[data-navigation-prev-button]');
    this.nextButton = this.getElement('[data-navigation-next-button]');
    this.currentCopy = this.getElement('[data-navigation-current-item]');
    this.progressLine = this.getElement('[data-navigation-progress-line]');
  }
  public adopted() {
    if (this.listItems.length > 1) {
      this.updateSlideNavigation();
    } else {
      this.listItems[0].classList.add('current');
      this.images[0].classList.add('current');
    }
    this.setWrapperHeight();
    this.addEventListeners();
  }
  private setWrapperHeight = () => {
    const maxHeight = this.listItems.reduce(
      (height, item) => Math.max(height, item.getBoundingClientRect().height),
      0,
    );
    this.list.style.height = `${maxHeight}px`;
  };
  private handlePrevClick = () => {
    this.currentSlide -= 1;
    this.updateSlideNavigation();
    this.currentCopy.innerText = formatNumber(`${this.currentSlide + 1}`, 2);
  };
  private handleNextClick = () => {
    this.currentSlide += 1;
    this.updateSlideNavigation();
    this.currentCopy.innerText = formatNumber(`${this.currentSlide + 1}`, 2);
  };
  private updateSlideNavigation(): void {
    this.prevButton.disabled = this.currentSlide === 0;
    this.nextButton.disabled = this.currentSlide === this.listItems.length - 1;
    const progressPercentage = -100 + ((this.currentSlide + 1) * 100) / this.listItems.length;
    this.progressLine.style.transform = `translateX(${progressPercentage}%)`;
    this.listItems.forEach((slide, index) => {
      if (index === this.currentSlide) {
        slide.classList.remove('next');
        slide.classList.remove('prev');
        slide.classList.add('current');
        this.images[index].classList.remove('next');
        this.images[index].classList.remove('prev');
        this.images[index].classList.add('current');
      } else if (index < this.currentSlide) {
        slide.classList.remove('next');
        slide.classList.remove('current');
        slide.classList.add('prev');
        this.images[index].classList.remove('next');
        this.images[index].classList.remove('current');
        this.images[index].classList.add('prev');
      } else if (index > this.currentSlide) {
        slide.classList.remove('current');
        slide.classList.remove('prev');
        slide.classList.add('next');
        this.images[index].classList.remove('current');
        this.images[index].classList.remove('prev');
        this.images[index].classList.add('next');
      }
    });
    this.handleResize();
  }
  private handleTouchStart = (event: TouchEvent): void => {
    this.posX = event.touches[0].clientX;
    this.posY = event.touches[0].clientY;
    this.isSwiping = true;
  };
  private handleTouchMove = (event: TouchEvent): void => {
    const { clientX, clientY } = event.touches[0];
    const diffX = (100 * (this.posX - clientX)) / this.windowWidth;
    const diffY = (100 * (this.posY - clientY)) / this.windowWidth;
    /* The if condition will consider a "short" or "long" swipe and trigger
     * either a 1 slide scroll, or an all slide scroll */
    if ((Math.abs(diffX) > 20 && this.isSwiping) || Math.abs(diffX) > 35) {
      switch (this.swipeDirection) {
        case 0:
          this.swipeDirection = Math.abs(diffX) > Math.abs(diffY) ? 1 : -1;
          break;
        case 1:
          if (diffX < 0) {
            this.posX = clientX;
            if (this.currentSlide !== 0) {
              this.handlePrevClick();
              this.isSwiping = false;
            }
          } else {
            event.preventDefault();
            this.posX = clientX;
            if (this.currentSlide < this.listItems.length - 1) {
              this.handleNextClick();
              this.isSwiping = false;
            }
          }
        default:
          break;
      }
    }
  };
  private handleTouchEnd = (event: TouchEvent): void => {
    this.swipeDirection = 0;
  };
  private handleResize = (): void => {
    if (this.windowWidth !== window.innerWidth) {
      this.list.style.height = 'auto';
      this.setWrapperHeight();
      this.windowWidth = window.innerWidth;
    }
  };
  private addEventListeners(): void {
    window.addEventListener('resize', this.handleResize);
    if (this.listItems.length > 1) {
      this.wrapper.addEventListener('touchmove', this.handleTouchMove);
      this.wrapper.addEventListener('touchstart', this.handleTouchStart);
      this.wrapper.addEventListener('touchend', this.handleTouchEnd);
      this.prevButton.addEventListener('click', this.handlePrevClick);
      this.nextButton.addEventListener('click', this.handleNextClick);
    }
  }
  private removeEventListeners(): void {
    window.removeEventListener('resize', this.handleResize);
    if (this.listItems.length > 1) {
      this.wrapper.removeEventListener('touchmove', this.handleTouchMove);
      this.wrapper.removeEventListener('touchstart', this.handleTouchStart);
      this.wrapper.removeEventListener('touchend', this.handleTouchEnd);
      this.prevButton.removeEventListener('click', this.handlePrevClick);
      this.nextButton.removeEventListener('click', this.handleNextClick);
    }
  }
  public dispose() {
    this.removeEventListeners();
    super.dispose();
  }
}

Creating a slider from scratch is not always the best and most of the times it's like reinventing the wheel 🎑

import AbstractBlock from 'app/component/block/AbstractBlock';

export default class C01SliderHero extends AbstractBlock {
  public static displayName: string = 'c01-slider-hero';
  constructor(el: any) {
    super(el);
    //@ts-ignore
    const slider = tns({
      container: '[data-tiny-slider]',
      mode: 'gallery',
      animateIn: 'transition-in',
      animateOut: 'transition-out',
      items: 1,
      slideBy: 'page',
      autoplay: true,
      arrowKeys: true,
      controlsPosition: 'bottom',
      controlsContainer: '.controls-container',
      navContainer: '.nav-container',
      autoplayButtonOutput: false,
      speed: 1000,
    });
    const buttons = this.getElements('[data-control]');
    buttons.forEach(el => el.addEventListener('click', this.stopAnim));
  }

  stopAnim = () => {
    this.element.classList.add('no-anim');
  };

  public dispose() {
    super.dispose();
    const buttons = this.getElements('[data-control]');
    buttons.forEach(el => el.removeEventListener('click', this.stopAnim));
  }
}

The after πŸ˜ƒ

We use a really small library (tiny-slider) with lots of options for customization, and with less than 50 LoC and some css magic we made it work πŸ§™β€β™‚οΈ

Accordeon πŸ’₯

The before πŸ˜…

import AbstractBlock from '../AbstractBlock';

export default class C10Accordion extends AbstractBlock {
  public static readonly displayName: string = 'c10-accordion';

  private readonly accordionButtons: ReadonlyArray<HTMLDivElement> = this.getElements(
    '[data-accordion-button]',
  );
  private readonly accordionPanels: ReadonlyArray<HTMLDivElement> = this.getElements(
    '[data-accordion-panel]',
  );

  private readonly accordionTitles: ReadonlyArray<HTMLDivElement> = this.getElements(
    '[data-accordion-title]',
  );
  private readonly accordionItems: ReadonlyArray<HTMLDivElement> = this.getElements(
    '[data-accordion-content]',
  );

  private prevIndex: number;

  private accordionButtonListeners: Array<EventListener> = [];

  constructor(el: HTMLElement) {
    super(el);

    this.prevIndex = 0;

    this.displayFirstItem();

    this.addEventListeners();
  }

  private toggleDisplay = (index: number) => {
    const clickedButton = this.accordionButtons[index];
    const clickedPanel = this.accordionPanels[index];

    clickedButton.classList.toggle('active');
    clickedPanel.style.display === 'block'
      ? (clickedPanel.style.display = 'none')
      : (clickedPanel.style.display = 'block');
  };

  private displayFirstItem = () => {
    const firstItem = this.accordionItems[0];

    firstItem.classList.toggle('active');
  };

  private toggleItem = (index: number) => {
    const prevItem = this.accordionItems[this.prevIndex];
    const clickedItem = this.accordionItems[index];

    prevItem.classList.toggle('active');
    clickedItem.classList.toggle('active');

    this.prevIndex = index;
  };

  private addEventListeners(): void {
    this.accordionButtons.forEach((element: HTMLElement, index: number) => {
      this.addListener(element, 'click', () => this.toggleDisplay(index));
    });
    this.accordionTitles.forEach((element: HTMLElement, index: number) => {
      this.addListener(element, 'click', () => this.toggleItem(index));
    });
  }

  public dispose() {
    super.dispose();
  }
}

Accordeons were a lot of fun in the past, small, simple component to create with javascript ⏳

The after πŸ€“

<details>
  <summary>Accordeon Title</summary>
  Content inside the accordeon. Yes! It just works! and it supports <span>more html tags!</span>.
</details>

But did you know accordeons (as <details> and <summary> tags) are part of the HTML spec now! They don't even need JS (maybe a little of css though) 🀯

Custom cursor 🏹

The before πŸ˜…

import AbstractComponent from '../../AbstractComponent';

import { TweenLite, Power3 } from 'gsap';
import lerp from '../../../util/lerp';
import bowser from 'bowser';

export default class M14CustomCursor extends AbstractComponent {
  public static displayName: string = 'm14-custom-cursor';

  private static readonly IS_ACTIVE: string = 'is-active';

  private blocks: Array<HTMLElement> = [];

  private isDesktop: boolean =
    (bowser.getParser(window.navigator.userAgent) as any).parsedResult.platform.type === 'desktop';
  private cursorSize: number = 0;
  private scale: number = 1;
  private isAnimating: boolean = false;
  private mousePosition: { x: number; y: number; lastX: number; lastY: number } = {
    x: 0,
    y: 0,
    lastX: 0,
    lastY: 0,
  };

  constructor(el: HTMLElement) {
    super(el);
  }

  public adopted() {
    if (this.isDesktop) {
      this.blocks = [].slice.call(document.querySelectorAll('[data-custom-cursor]'));
      this.cursorSize = this.element.clientWidth;
      this.updatePosition();
      this.addEventListeners();
    }
  }

  private handleMouseMove = (event: MouseEvent) => {
    this.mousePosition.x = event.clientX - this.cursorSize * 0.5;
    this.mousePosition.y = event.clientY + this.cursorSize * 0.25;
  };

  private handleMouseDown = () => {
    TweenLite.to(this, 0.6, { scale: 0.85, ease: Power3.easeOut });
    if (this.isAnimating) document.body.style.cursor = 'grabbing';
  };

  private handleMouseUp = () => {
    TweenLite.to(this, 0.4, { scale: 1, ease: Power3.easeOut });
    if (this.isAnimating) document.body.style.cursor = 'grab';
  };

  private updatePosition = () => {
    requestAnimationFrame(this.updatePosition);

    this.mousePosition.lastX = lerp(this.mousePosition.lastX, this.mousePosition.x, 0.1);
    this.mousePosition.lastY = lerp(this.mousePosition.lastY, this.mousePosition.y, 0.1);

    if (!this.isAnimating) return;

    this.element.style.transform = `translate3d(${this.mousePosition.lastX}px, ${
      this.mousePosition.lastY
    }px, 0) scale(${this.scale})`;
  };

  private handleMouseEnter = () => {
    this.isAnimating = true;
    document.body.style.cursor = 'grab';
    this.element.classList.add(M14CustomCursor.IS_ACTIVE);
  };

  private handleMouseLeave = () => {
    this.isAnimating = false;
    document.body.style.cursor = '';
    this.element.classList.remove(M14CustomCursor.IS_ACTIVE);
  };

  private addEventListeners = () => {
    this.blocks.forEach((element: HTMLElement) => {
      element.addEventListener('mouseenter', this.handleMouseEnter);
      element.addEventListener('mouseleave', this.handleMouseLeave);
    });

    document.addEventListener('mousemove', this.handleMouseMove);
    document.addEventListener('mousedown', this.handleMouseDown);
    document.addEventListener('mouseup', this.handleMouseUp);
  };

  private removeEventListeners = () => {
    document.removeEventListener('mousemove', this.handleMouseMove);
    document.removeEventListener('mousedown', this.handleMouseDown);
    document.removeEventListener('mouseup', this.handleMouseUp);
  };

  public dispose() {
    this.removeEventListeners();
    super.dispose();
  }
}

This cursor is really nice, but in order to make some changes requested, it was going to take a lot of time (remember TS is my nemesis 😿)

The after πŸ’…

.slider {
  cursor: url("../../../../static/drag.svg"), pointer;
}

CSSΒ  TO THE RESCUE! YES! πŸ’–

And remember the slider behind the moving

cursor? πŸ”Ž

import AbstractBlock from '../AbstractBlock';
import { scrollTransition, baseComponentTransition } from '../../../util/transitions';

export default class C09TimelineCarousel extends AbstractBlock {
  public static readonly displayName: string = 'c09-timeline-carousel';

  constructor(el: HTMLElement) {
    super(el);
    //@ts-ignore
    const slider = tns({
      container: '[data-tiny-slider]',
      mouseDrag: true,
      loop: false,
      fixedWidth: 240,
      controls: false,
      nav: false,
      gutter: 52,
      responsive: {
        1024: {
          fixedWidth: 305,
          gutter: 185,
        },
      },
    });

    const transitionItems = this.getElements('[data-transition-item]');

    scrollTransition(el, baseComponentTransition(transitionItems));
  }

  public dispose() {
    super.dispose();
  }
}

We also used the previous library 🎁

AND πŸ‘ Β IT πŸ‘ JUST πŸ‘ WORKS πŸ‘ (yes, media-queries and all)

Offset columns?

Who knows you papΓ‘?


@mixin offsetFullWidth($columnsWanted, $total: 12, $direction) {
  $columnsTotal: $total;
  $magicNumber: 20;
  @if $columnsTotal == 12 {
    $magicNumber: 13.333;
  }
  $gapSize: 55;
  $containerPadding: 80;
  $percentage: (($columnsWanted / $columnsTotal) * 100) * 1%;
  $offset: ($gapSize / $columnsTotal) * ($columnsTotal - $columnsWanted) + px;
  $finalOffset: $containerPadding + ($gapSize - ($columnsWanted * $magicNumber)) + px;
  width: 100%;
  #{$direction}: calc(#{$percentage} - #{$offset} + #{$finalOffset});
}

SASS Mixings to the rescue πŸ¦Έβ€β™€οΈ

Instead of doingΒ  magic with javascript every time the user loads and resizes we figured out we could rely on just CSS (don't know why the 20 works btw πŸ§™β€β™‚οΈ )

Conclusion

  • No, most of the work it's not already done.
  • It's better to start over that trying to adapt the new design with lots ofΒ  hacks.
  • You don't necessary have to use the same old implementations.

Thank you!

Exiros -

By Gabriel Miranda