Things I learned working in a recycled website
(and I liked them π )
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));
}
}
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 π§ββοΈ
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 β³
<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) π€―
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 πΏ)
.slider {
cursor: url("../../../../static/drag.svg"), pointer;
}
CSS TO THE RESCUE! YES! π
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();
}
}
AND π IT π JUST π WORKS π (yes, media-queries and all)
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});
}
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 π§ββοΈ )