Snow owl
The snowy owl (Bubo scandiacus), also known as the polar owl, the white owl and the Arctic owl, is a large, white owl of the true owl family. Snowy owls are native to the Arctic regions.
Photo by Doug Kelley
const { ActiveList, createActiveListSubscriber } = require('@uiloos/core');
// Get a reference to the carousel element
const carouselEl = document.querySelector('.snap-carousel-slides');
// Get a reference to the progress element
const progressEl = document.getElementById('snap-carousel-progress');
// Unfortunately there is no way of knowing whether or
// not a scroll happened programmatically via "scrollIntoView"
// or by the end user.
let isScrolling = false;
const carousel = new ActiveList(
{
// The slides will be the contents of the ActiveList
contents: Array.from(carouselEl.querySelectorAll('.slide')),
// Start at the second slide so there is content
// on both the left and the right, if started on the
// 0 index, we would show a slide without a previous
// slide, which looks sloppy.
activeIndexes: [0, 1],
// The last activated slide is the one we are going to show.
// The slide that was active two slides ago we are going to
// move. The trick here is that while the ActiveList considers
// three slides to be active, we only consider the last active
// slide active.
maxActivationLimit: 3,
autoPlay: {
// Each slide should take 5000 milliseconds, note
// that you can also provide a function so each
// slide has a unique duration.
duration: 5000,
},
// Make the last slide go to the first slice and vice versa
isCircular: true,
},
createActiveListSubscriber({
onInitialized(event, carousel) {
// Start the progress animation
progressEl.style.animation = `progress ${carousel.autoPlay.duration}ms linear`;
// Since we show the "second" slide initially have to
// scroll instantly to that slide on initialization.
carouselEl.scrollTo({
top: 0,
left: carousel.lastActivatedIndex * carouselEl.clientWidth,
behavior: 'instant',
});
},
onAutoPlayPaused() {
// Halt the animation when paused
progressEl.style.animationPlayState = 'paused';
},
onAutoPlayPlaying() {
// Resume animation when playing
progressEl.style.animationPlayState = 'running';
},
onAutoPlayStopped() {
// Remove the progress indicator now that the user has
// assumed full control over the carousel.
progressEl.style.background = 'transparent';
},
onActivated(event, carousel) {
// Mark that the carousel started scrolling
isScrolling = true;
// scroll the carousel to the activated slide.
carouselEl.scrollTo({
top: 0,
left: carousel.lastActivatedIndex * carouselEl.clientWidth,
behavior: 'smooth',
});
// Technically the animation needs not be reset,
// since all slides have the same duration. But
// if you'd change the autoPlay duration to a
// function the progress would be wrong.
progressEl.style.animation = `progress ${carousel.autoPlay.duration}ms linear`;
// By removing the node and inserting it again
// the animation is restarted.
progressEl.remove();
carouselEl.parentElement.insertBefore(progressEl, carouselEl);
// Now this is the magic trick: we take the previous
// slide and move it to the last position. This creates
// an infinitely scrolling snap carousel.
// But because we show a little of the the previous
// and next slides, (peek ahead) we need actually need to move
// the slide that was shown two iterations ago.
const previousPreviousSlide = carousel.activeContents[0];
// Do perform the move after a timeout however, so the move
// does not affect the smooth scroll of the next slide.
window.setTimeout(() => {
// When you append an element which is already a child
// it will get moved, so there is no need to remove the
// element first.
carouselEl.append(previousPreviousSlide.value);
// Now also update the ActiveList itself
previousPreviousSlide.moveToLast();
// Reset the scrollLeft, needed for Safari
// and FireFox otherwise the wrong slide
// will be shown.
carouselEl.scrollLeft = carouselEl.clientWidth;
}, 1000);
},
})
);
// Disable the carousel when users touches the carousel
let touchXStart = 0;
const TOUCH_STOP_DISTANCE = 20;
// Disable the carousel when users touches the carousel
carouselEl.addEventListener('touchstart', (event) => {
touchXStart = event.changedTouches[0].screenX;
});
carouselEl.addEventListener('touchmove', (event) => {
const touchXCurrent = event.changedTouches[0].screenX;
const distance = Math.abs(touchXCurrent - touchXStart);
if (distance > TOUCH_STOP_DISTANCE) {
carousel.stop();
}
});
// Disable the carousel when users mouse enters the carousel
carouselEl.addEventListener('mouseenter', () => {
carousel.pause();
});
// Enable the carousel again when users mouse exits the carousel.
carouselEl.addEventListener('mouseleave', () => {
if (!carousel.autoPlay.hasBeenStoppedBefore) {
carousel.play();
}
});
carouselEl.addEventListener('scroll', () => {
// In browser that do not support "onscrollend"
// stop the carousel when not playing. This does
// lead to a funky behavior: when the carousel
// is scrolling and the user hovers over the
// carousel at that moment, the carousel will
// stop.
if (!('onscrollend' in window) && !carousel.autoPlay.isPlaying) {
carousel.stop();
return;
}
// If the carousel is scrolling automatically no not stop.
if (isScrolling) {
return;
}
// The scroll event is also triggered by the ACTIVATED scrollIntoView
// and we want to ignore that. We know when the user hovers over the
// carousel that is is "paused", so if it is playing at this moment
// we know it is an carousel ACTIVATED scroll
if (!carousel.autoPlay.isPlaying) {
carousel.stop();
}
});
// Note: "safari" does not support "scrollend" yet.
// This is called multiple times for some reason,
// when scrolling via "scrollIntoView" so it must be debounced.
let peekScrollEndTimerId = -1;
carouselEl.addEventListener('scrollend', () => {
window.clearTimeout(peekScrollEndTimerId);
peekScrollEndTimerId = window.setTimeout(() => {
isScrolling = false;
}, 500);
});
<div class="snap-carousel">
<div
id="snap-carousel-progress"
class="snap-carousel-progress"
></div>
<!-- Slides -->
<ul class="snap-carousel-slides">
<li id="snap-carousel-slide-0" class="slide active">
<picture>
<source type="image/avif" srcset="/images/owls/snow.avif" />
<img
width="1920"
height="1280"
src="/images/owls/snow.jpg"
alt="image of a snow owl"
/>
</picture>
<article>
<h4>Snow owl</h4>
<p>
The snowy owl (Bubo scandiacus), also known as the polar owl, the
white owl and the Arctic owl, is a large, white owl of the true owl
family. Snowy owls are native to the Arctic regions.
</p>
<p>
Photo by
<a
href="https://unsplash.com/@dkphotos"
target="_blank"
rel="noopener noreferrer"
>Doug Kelley</a
>
</p>
</article>
</li>
<li id="snap-carousel-slide-1" class="slide">
<picture>
<source type="image/avif" srcset="/images/owls/tawny.avif">
<img width="1920" height="1280" src="/images/owls/tawny.jpg" alt="image of a tawny owl" />
</picture>
<article>
<h4>Tawny owl</h4>
<p>
The tawny owl (also called the brown owl; Strix aluco) is commonly
found in woodlands across much of Eurasia and North Africa, and has 11
recognized subspecies.
</p>
<p>
Photo by
<a
href="https://unsplash.com/@kai_wenzel"
target="_blank"
rel="noopener noreferrer"
>Kai Wenzel</a
>
</p>
</article>
</li>
<li id="snap-carousel-slide-2" class="slide">
<picture>
<source type="image/avif" srcset="/images/owls/barn.avif" />
<img
width="1920"
height="1280"
src="/images/owls/barn.jpg"
alt="image of a barn owl"
/>
</picture>
<article>
<h4>Barn owl</h4>
<p>
The barn owl (Tyto alba) is the most widely distributed species of owl
in the world and one of the most widespread of all species of birds,
being found almost everywhere in the world.
</p>
<p>
Photo by
<a
href="https://unsplash.com/@picsbyjameslee"
target="_blank"
rel="noopener noreferrer"
>James Lee</a
>
</p>
</article>
</li>
<li id="snap-carousel-slide-3" class="slide">
<picture>
<source type="image/avif" srcset="/images/owls/burrowing.avif" />
<img
width="1920"
height="1280"
src="/images/owls/burrowing.jpg"
alt="image of a burrowing owl"
/>
</picture>
<article>
<h4>Burrowing owl</h4>
<p>
The burrowing owl (Athene cunicularia) is a small, long-legged owl.
Burrowing owls nest and roost in burrows, such as those excavated by
prairie dogs.
</p>
<p>
Photo by
<a
href="https://unsplash.com/@rayhennessy"
target="_blank"
rel="noopener noreferrer"
>Ray Hennessy</a
>
</p>
</article>
</li>
<li id="snap-carousel-slide-4" class="slide">
<picture>
<source type="image/avif" srcset="/images/owls/hawk.avif" />
<img
width="1920"
height="1280"
src="/images/owls/hawk.jpg"
alt="image of a northern hawk-owl"
/>
</picture>
<article>
<h4>Northern hawk-owl</h4>
<p>
The northern hawk-owl or northern hawk owl (Surnia ulula) is a
medium-sized true owl of the northern latitudes. It is one of the few
owls that is neither nocturnal nor crepuscular.
</p>
<p>
Photo by
<a
href="https://unsplash.com/@erik_karits"
target="_blank"
rel="noopener noreferrer"
>Erik Karits</a
>
</p>
</article>
</li>
</ul>
</div>
.snap-carousel {
display: grid;
justify-content: center;
}
.snap-carousel-slides {
display: flex;
gap: 8px;
max-width: 640px;
overflow-x: auto;
scroll-snap-type: x mandatory;
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
list-style-type: none;
}
.snap-carousel .slide {
/*
Show the previous and next slides a little,
I call this peek ahead
*/
min-width: calc(640px - 48px);
scroll-snap-align: center;
}
/* @media only screen and (max-width: 1200px) {
snap-carousel-slides {
max-width: 300px;
}
.snap-carousel .slide {
min-width: calc(300px - 16px);
background-color: blue;
}
} */
/* Hide scrollbar for Chrome, Safari and Opera */
.snap-carousel-slides::-webkit-scrollbar {
display: none;
}
.snap-carousel .slide img {
max-width: 100%;
height: auto;
}
.snap-carousel article {
padding: 12px 0px;
}
.snap-carousel article h4 {
margin-bottom: 8px;
}
.snap-carousel-progress {
margin-bottom: 4px;
height: 8px;
background: linear-gradient(
to right,
rgb(107 33 168) 50%,
rgb(102, 102, 102) 50%
);
background-size: 200% 100%;
}
@keyframes progress {
from {
background-position: right bottom;
}
to {
background-position: left bottom;
}
}
@media only screen and (max-width: 680px) {
.snap-carousel-slides {
max-width: 320px;
}
.snap-carousel .slide {
max-width: 320px;
min-width: 320px;
}
}
const { ActiveList, createActiveListSubscriber } = require('@uiloos/core');
const cardEls = document.querySelectorAll('.card-container');
const ANIMATION_DURATION = 300;
const carousel = new ActiveList(
{
// The cards will be the contents of the ActiveList
contents: cardEls,
// Select the center image as the starting point,
// this way there will always be a left and right
// image.
activeIndexes: Math.ceil(cardEls.length / 2),
// Make the last slide go to the first slice and vice versa
isCircular: true,
// Every 2 seconds to to the next slide automatically,
// but stop auto playing whenever the user starts manually
// sliding.
autoPlay: {
duration: 2000,
stopsOnUserInteraction: true,
},
// By setting a cooldown we only allow sliding when the animation
// has finished.
cooldown: ANIMATION_DURATION,
},
createActiveListSubscriber({
onInitialized(event, carousel) {
positionCards(carousel);
},
onActivated(event, carousel) {
if (carousel.direction === 'right') {
// Move first image to last when moving right
carousel.contents.at(0).moveToLast();
} else {
// Move last image to first when moving left
carousel.contents.at(-1).moveToFirst();
}
positionCards(carousel);
},
})
);
function positionCards(carousel) {
carousel.contents.forEach((content) => {
// Provide all CSS variables needed for the animation.
// Visible cards from the center, show less on mobile devices
const visibleCards = window.innerWidth < 500 ? 2 : 3;
const xPosition = carousel.lastActivatedIndex - content.index;
const absoluteX = Math.abs(xPosition);
content.value.style.setProperty(
'--offset',
xPosition / visibleCards
);
content.value.style.setProperty('--direction', Math.sign(xPosition));
content.value.style.setProperty(
'--abs-offset',
absoluteX / visibleCards
);
// Hide cards when not visible, by setting they opacity
content.value.style.opacity =
absoluteX >= visibleCards ? '0' : '1';
});
}
// Clicking the card activates it.
carousel.contents.forEach((content) => {
content.value.onclick = () => {
// Activate this card and stop the autoplay
content.activate();
// Note: above line is the same as calling:
// content.activate({ isUserInteraction: true });
};
});
<div class="cover-flow">
<div class="carousel">
<div class="card-container">
<div class="card">
<picture>
<source type="image/avif" srcset="/images/owls/snow.avif" />
<img
width="1920"
height="1280"
src="/images/owls/snow.jpg"
alt="image of a snow owl"
/>
</picture>
</div>
</div>
<div class="card-container">
<div class="card">
<picture>
<source type="image/avif" srcset="/images/owls/tawny.avif" />
<img
width="1920"
height="1280"
src="/images/owls/tawny.jpg"
alt="image of a tawny owl"
/>
</picture>
</div>
</div>
<div class="card-container">
<div class="card">
<picture>
<source type="image/avif" srcset="/images/owls/barn.avif" />
<img
width="1920"
height="1280"
src="/images/owls/barn.jpg"
alt="image of a barn owl"
/>
</picture>
</div>
</div>
<div class="card-container">
<div class="card">
<picture>
<source type="image/avif" srcset="/images/owls/burrowing.avif" />
<img
width="1920"
height="1280"
src="/images/owls/burrowing.jpg"
alt="image of a burrowing owl"
/>
</picture>
</div>
</div>
<div class="card-container">
<div class="card">
<picture>
<source type="image/avif" srcset="/images/owls/hawk.avif" />
<img
width="1920"
height="1280"
src="/images/owls/hawk.jpg"
alt="image of a northern hawk-owl"
/>
</picture>
</div>
</div>
<div class="card-container">
<div class="card">
<picture>
<source type="image/avif" srcset="/images/owls/white-faced.avif" />
<img
width="1920"
height="1280"
src="/images/owls/white-faced.jpg"
alt="image of a southern white faced owl"
/>
</picture>
</div>
</div>
</div>
</div>
.cover-flow {
display: grid;
justify-content: center;
margin-bottom: 42px;
overflow: hidden;
}
.cover-flow .carousel {
position: relative;
width: 300px;
height: 400px;
perspective: 500px;
transform-style: preserve-3d;
}
.cover-flow .card-container {
position: absolute;
width: 100%;
height: 100%;
transform: rotateY(calc(var(--offset) * 50deg))
scaleY(calc(1 + var(--abs-offset) * -0.4))
translateZ(calc(var(--abs-offset) * -30rem))
translateX(calc(var(--direction) * -5rem));
filter: blur(calc(var(--abs-offset) * 1rem));
transition: all 0.3s ease-out;
}
.cover-flow .card {
width: 100%;
height: 100%;
padding: 0;
}
.cover-flow img, .cover-flow picture {
height: 100%;
width: 100%;
object-fit: cover;
}
.cover-flow button {
color: black;
font-size: 5rem;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 50%;
z-index: 2;
cursor: pointer;
}
import { Typewriter } from '@uiloos/core';
const typewriterEl = document.getElementById('multicursor-typewriter');
function subscriber(typewriter) {
// Clear the typewriters HTML
typewriterEl.innerHTML = '';
for (const position of typewriter) {
// If there are multiple cursors, the last one will be on top
for (const cursor of position.cursors.reverse()) {
const color = cursor.data.color;
const cursorEl = document.createElement("span");
cursorEl.classList.add("cursor");
cursorEl.style.setProperty("--cursor-color", color);
if (cursor.isBlinking) {
cursorEl.classList.add("blink");
}
typewriterEl.append(cursorEl);
const infoEl = document.createElement("span");
infoEl.className = "info";
infoEl.style.setProperty("--cursor-color", color);
infoEl.textContent = cursor.data.name;
typewriterEl.append(infoEl);
}
const letterEl = document.createElement('span');
letterEl.className = 'letter';
letterEl.textContent = position.character;
typewriterEl.append(letterEl);
for (const cursor of position.selected.reverse()) {
// This span has one or multiple cursors, the last one will win.
const color = cursor.data.color;
letterEl.style.setProperty('--background-color', color + '30'); // 30 = opacity
}
}
}
const config = {
// Blink the cursus after 250ms of inactivity.
blinkAfter: 250,
// Repeat the animation forever
repeat: true,
// But wait 10s before starting the animation again.
repeatDelay: 10000,
// Play the animation straight away.
autoPlay: true,
// Defines the cursors and their initial positions,
// note that "data" can contain be anything you want!
cursors: [
{
position: 0,
data: {
name: 'Jim',
color: '#ef4444',
}
},
{
position: 0,
data: {
name: 'Dwight',
color: '#d946ef',
}
},
{
position: 0,
data: {
name: 'Pam',
color: '#22c55e',
}
},
{
position: 0,
data: {
name: 'Michael',
color: '#3b82f6',
}
},
],
// These actions where generated using the typewriter composer:
// http://localhost:8080/docs/typewriter/composer/
actions: [
{
type: 'keyboard',
cursor: 0,
text: 'W',
delay: 50,
},
{
type: 'keyboard',
cursor: 0,
text: 'i',
delay: 87,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 141,
},
{
type: 'keyboard',
cursor: 0,
text: 'h',
delay: 76,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 99,
},
{
type: 'keyboard',
cursor: 0,
text: 'c',
delay: 44,
},
{
type: 'keyboard',
cursor: 0,
text: 'o',
delay: 79,
},
{
type: 'keyboard',
cursor: 0,
text: 'l',
delay: 30,
},
{
type: 'keyboard',
cursor: 0,
text: 'l',
delay: 113,
},
{
type: 'keyboard',
cursor: 0,
text: 'a',
delay: 80,
},
{
type: 'keyboard',
cursor: 0,
text: 'b',
delay: 44,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 72,
},
{
type: 'keyboard',
cursor: 0,
text: 'y',
delay: 64,
},
{
type: 'keyboard',
cursor: 0,
text: 'o',
delay: 87,
},
{
type: 'keyboard',
cursor: 0,
text: 'u',
delay: 56,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 80,
},
{
type: 'keyboard',
cursor: 0,
text: 'c',
delay: 88,
},
{
type: 'keyboard',
cursor: 0,
text: 'a',
delay: 84,
},
{
type: 'keyboard',
cursor: 0,
text: 'n',
delay: 59,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 73,
},
{
type: 'keyboard',
cursor: 0,
text: 'c',
delay: 8,
},
{
type: 'keyboard',
cursor: 0,
text: 'r',
delay: 44,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 76,
},
{
type: 'keyboard',
cursor: 0,
text: 'a',
delay: 108,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 100,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 47,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 133,
},
{
type: 'keyboard',
cursor: 0,
text: 'w',
delay: 177,
},
{
type: 'keyboard',
cursor: 0,
text: 'i',
delay: 102,
},
{
type: 'keyboard',
cursor: 0,
text: 'k',
delay: 160,
},
{
type: 'keyboard',
cursor: 0,
text: 'i',
delay: 152,
},
{
type: 'keyboard',
cursor: 0,
text: 's',
delay: 116,
},
{
type: 'keyboard',
cursor: 0,
text: ',',
delay: 124,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 83,
},
{
type: 'keyboard',
cursor: 0,
text: 'd',
delay: 53,
},
{
type: 'keyboard',
cursor: 0,
text: 'o',
delay: 64,
},
{
type: 'keyboard',
cursor: 0,
text: 'c',
delay: 125,
},
{
type: 'keyboard',
cursor: 0,
text: 'u',
delay: 108,
},
{
type: 'keyboard',
cursor: 0,
text: 'm',
delay: 55,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 109,
},
{
type: 'keyboard',
cursor: 0,
text: 'n',
delay: 87,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 100,
},
{
type: 'keyboard',
cursor: 0,
text: 's',
delay: 96,
},
{
type: 'keyboard',
cursor: 0,
text: ',',
delay: 75,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 40,
},
{
type: 'keyboard',
cursor: 0,
text: 'p',
delay: 100,
},
{
type: 'keyboard',
cursor: 0,
text: 'r',
delay: 106,
},
{
type: 'keyboard',
cursor: 0,
text: 's',
delay: 100,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 139,
},
{
type: 'keyboard',
cursor: 0,
text: 'n',
delay: 132,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 104,
},
{
type: 'keyboard',
cursor: 0,
text: 'a',
delay: 55,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 160,
},
{
type: 'keyboard',
cursor: 0,
text: 'i',
delay: 78,
},
{
type: 'keyboard',
cursor: 0,
text: 'o',
delay: 61,
},
{
type: 'keyboard',
cursor: 0,
text: 'n',
delay: 44,
},
{
type: 'keyboard',
cursor: 0,
text: 's',
delay: 50,
},
{
type: 'keyboard',
cursor: 0,
text: ',',
delay: 44,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 84,
},
{
type: 'keyboard',
cursor: 0,
text: 'a',
delay: 100,
},
{
type: 'keyboard',
cursor: 0,
text: 'n',
delay: 99,
},
{
type: 'keyboard',
cursor: 0,
text: 'd',
delay: 84,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 61,
},
{
type: 'keyboard',
cursor: 0,
text: 'p',
delay: 124,
},
{
type: 'keyboard',
cursor: 0,
text: 'r',
delay: 151,
},
{
type: 'keyboard',
cursor: 0,
text: 'o',
delay: 105,
},
{
type: 'keyboard',
cursor: 0,
text: 'j',
delay: 60,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 49,
},
{
type: 'keyboard',
cursor: 0,
text: 'c',
delay: 88,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 158,
},
{
type: 'keyboard',
cursor: 0,
text: 'z',
delay: 88,
},
{
type: 'keyboard',
cursor: 0,
text: ' ',
delay: 156,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 43,
},
{
type: 'keyboard',
cursor: 0,
text: 'o',
delay: 63,
},
{
type: 'keyboard',
cursor: 0,
text: 'g',
delay: 33,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 144,
},
{
type: 'keyboard',
cursor: 0,
text: 't',
delay: 87,
},
{
type: 'keyboard',
cursor: 0,
text: 'h',
delay: 43,
},
{
type: 'keyboard',
cursor: 0,
text: 'e',
delay: 96,
},
{
type: 'keyboard',
cursor: 0,
text: 'r',
delay: 69,
},
{
type: 'keyboard',
cursor: 0,
text: '.',
delay: 43,
},
{
type: 'mouse',
cursor: 1,
position: 5,
delay: 50,
},
{
type: 'keyboard',
cursor: 1,
text: '"',
delay: 500,
},
{
type: 'mouse',
cursor: 1,
position: 12,
delay: 34,
},
{
type: 'keyboard',
cursor: 1,
text: '"',
delay: 500,
},
{
type: 'mouse',
cursor: 1,
position: 49,
delay: 50,
},
{
type: 'keyboard',
cursor: 1,
text: 'e',
delay: 500,
},
{
type: 'mouse',
cursor: 1,
position: 74,
delay: 50,
},
{
type: 'keyboard',
cursor: 1,
text: '⌫',
delay: 500,
},
{
type: 'keyboard',
cursor: 1,
text: 's',
delay: 66,
},
{
type: 'mouse',
cursor: 2,
position: 45,
selection: {
start: 36,
end: 45,
},
delay: 70,
},
{
type: 'keyboard',
cursor: 2,
text: 'd',
delay: 1000,
},
{
type: 'keyboard',
cursor: 2,
text: 'o',
delay: 178,
},
{
type: 'keyboard',
cursor: 2,
text: 'c',
delay: 101,
},
{
type: 'keyboard',
cursor: 2,
text: 's',
delay: 73,
},
{
type: 'mouse',
cursor: 2,
position: 55,
selection: {
start: 42,
end: 55,
},
delay: 76,
},
{
type: 'keyboard',
cursor: 2,
text: 's',
delay: 1000,
},
{
type: 'keyboard',
cursor: 2,
text: 'l',
delay: 131,
},
{
type: 'keyboard',
cursor: 2,
text: 'i',
delay: 122,
},
{
type: 'keyboard',
cursor: 2,
text: 'd',
delay: 88,
},
{
type: 'keyboard',
cursor: 2,
text: 'e',
delay: 100,
},
{
type: 'keyboard',
cursor: 2,
text: 's',
delay: 160,
},
{
type: 'mouse',
cursor: 2,
position: 72,
delay: 50,
},
{
type: 'keyboard',
cursor: 2,
text: '⌫',
delay: 150,
},
{
type: 'keyboard',
cursor: 2,
text: ' ',
delay: 82,
},
{
type: 'keyboard',
cursor: 2,
text: '❤️',
delay: 50,
},
{
type: 'keyboard',
cursor: 3,
text: ' ',
delay: 50,
},
{
type: 'keyboard',
cursor: 3,
text: '⌫',
delay: 50,
},
{
type: 'mouse',
cursor: 3,
position: 74,
delay: 100,
},
{
type: 'keyboard',
cursor: 3,
text: ' ',
delay: 125,
},
{
type: 'keyboard',
cursor: 3,
text: 'N',
delay: 100,
},
{
type: 'keyboard',
cursor: 3,
text: 'o',
delay: 77,
},
{
type: 'keyboard',
cursor: 3,
text: 'w',
delay: 92,
},
{
type: 'keyboard',
cursor: 3,
text: ' ',
delay: 65,
},
{
type: 'keyboard',
cursor: 3,
text: 'w',
delay: 100,
},
{
type: 'keyboard',
cursor: 3,
text: 'i',
delay: 86,
},
{
type: 'keyboard',
cursor: 3,
text: 't',
delay: 53,
},
{
type: 'keyboard',
cursor: 3,
text: 'h',
delay: 87,
},
{
type: 'keyboard',
cursor: 3,
text: ' ',
delay: 136,
},
{
type: 'keyboard',
cursor: 3,
text: 'A',
delay: 78,
},
{
type: 'keyboard',
cursor: 3,
text: 'I',
delay: 114,
},
{
type: 'keyboard',
cursor: 3,
text: '!',
delay: 44,
},
]
};
new Typewriter(config, subscriber);
<div id="multicursor-typewriter"></div>
/* Note: --cursor-color, and --background-color are set from JavaScript */
#multicursor-typewriter {
position: relative;
font-size: 32px;
display: inline-block;
width: 100%;
min-height: 150px;
margin-bottom: 8px;
}
#multicursor-typewriter .info {
position: absolute;
display: inline;
margin-top: -20px;
margin-left: -1px;
padding: 4px;
font-family: sans-serif;
font-weight: bold;
font-size: 12px;
background-color: var(--cursor-color);
color: white;
}
#multicursor-typewriter .letter {
background-color: var(--background-color);
padding: 0;
}
#multicursor-typewriter .cursor {
display: inline-block;
position: absolute;
width: 2px;
height: 32px;
margin-left: -1px;
margin-top: 6px;
background-color: var(--cursor-color);
}
#multicursor-typewriter .blink {
animation: multicursor-blink 1s step-start infinite;
}
@keyframes multicursor-blink {
from,
to {
background-color: transparent;
}
50% {
background-color: var(--cursor-color);
}
}
@media only screen and (max-width: 680px) {
#multicursor-typewriter {
padding: 20px;
min-height: 300px;
}
}
/*
It is highly recommended to use a date library parse / validate
dates, using date-fns here, but you could also use Luxon, dayjs or
Moment.js
*/
import { isValid } from 'date-fns';
import {
DateGallery,
ActiveList,
createActiveListSubscriber,
} from '@uiloos/core';
// You can change the number of hours shown here,
// by default all hours are shown.
const WEEK_START_HOUR = 9;
const WEEK_END_HOUR = 18;
const WEEK_HEIGHT = (WEEK_END_HOUR - WEEK_START_HOUR) * 60;
// You can change the number of hours shown here,
// by default all hours are shown.
const DAY_START_HOUR = 9;
const DAY_END_HOUR = 18;
const DAY_WIDTH = (DAY_END_HOUR - DAY_START_HOUR) * 60;
let id = 0;
// December 2002
export const monthAndYearFormatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
year: 'numeric',
});
// 2000-01-01
export const isoFormatter = new Intl.DateTimeFormat('fr-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// 12:45
export const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // 00:00 to 23:59 instead of 24:00
});
// 2000
export const yearFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
});
// December
export const monthFormatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
});
// 12-31-2000
export const dateFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// 12-31-2000, 12:34
export const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // 00:00 to 23:59 instead of 24:00
});
// Monday 1
export const weekDayFormatter = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
weekday: 'short',
});
class Calendar extends HTMLElement {
connectedCallback() {
// Write the HTML into the <uiloos-calendar /> component needed
// for the component to render.
this.writeHTML();
// For each mode write the <templates> that the modes need
// this is so the <templates> used per mode is located in
// the file that renders the mode.
writeYearTemplates(this);
writeMonthTemplates(this);
writeWeekTemplates(this);
writeDayTemplates(this);
// The formEvent tracks which event is shown in the <form>
// element, when null it means a new event is added.
this.formEvent = null;
this.calendarWrapperEl = this.querySelector('.calendar-wrapper');
this.calendarTitleEl = this.querySelector('.calendar-title');
this.calendarEventFormEl = this.querySelector('.calendar-event-form');
this.addEventDialogEl = this.querySelector('dialog');
this.deleteButtonEl = this.querySelector('.delete-event-button');
this.modeButtonsEls = Array.from(this.querySelectorAll('.mode-button'));
const { mode, numberOfFrames, initialDate } = this.readConfigFromUrl();
// An array which holds ids returned from setInterval calls,
// is cleared after a mode change. The idea is that some modes
// may use window.setInterval to register some recurring action
// but that these should be cleanup whenever the mode changes.
this.intervalsIds = [];
const calendar = this;
this.dateGallery = new DateGallery(
{
mode,
numberOfFrames,
initialDate,
events: generateEvents(),
},
(dateGallery) => {
// Sync the query parameters, so that the url changes and the
// user can reload the page and still see the same calendar
const url = new URL(window.location.href);
url.searchParams.set('mode', dateGallery.mode);
url.searchParams.set(
'initialDate',
isoFormatter.format(dateGallery.firstFrame.anchorDate)
);
// Only push if the url has actually changed,
// otherwise it will push duplicates.
if (url.href !== window.location.href) {
window.history.pushState({}, '', url);
}
// The day mode runs an
calendar.intervalsIds.forEach((id) => {
clearInterval(id);
});
calendar.intervalsIds.length = 0;
// The delay is needed so modeSegmentedButton is initialized
setTimeout(() => {
// Sync with the mode segemented button
calendar.activateMode(dateGallery.mode);
}, 1);
// Clear the wrapper so the mode can render cleanly
calendar.calendarWrapperEl.innerHTML = '';
// Delegate the rendering of the actual mode to
// the various render helper functions.
if (dateGallery.mode === 'month-six-weeks') {
renderMonthCalendar(calendar, dateGallery);
} else if (dateGallery.mode === 'month') {
renderYearCalendar(calendar, dateGallery);
} else if (dateGallery.mode === 'week') {
renderWeekCalendar(calendar, dateGallery);
} else {
renderDayCalendar(calendar, dateGallery);
}
}
);
window.addEventListener('popstate', this.syncFromUrl);
this.modeSegmentedButton = new ActiveList(
{
contents: this.modeButtonsEls,
},
createActiveListSubscriber({
onActivated(event, modeSegmentedButton) {
if (modeSegmentedButton.lastDeactivated) {
modeSegmentedButton.lastDeactivated.classList.remove('active');
}
modeSegmentedButton.lastActivated.classList.add('active');
// Sync with the DateGallery
calendar.activateMode(modeSegmentedButton.lastActivated.dataset.mode);
},
})
);
this.activateMode(mode);
this.registerInteractions();
}
disconnectedCallback() {
window.removeEventListener('popstate', this.syncFromUrl);
this.calendarWrapperEl = null;
this.calendarTitleEl = null;
this.calendarEventFormEl = null;
this.addEventDialogEl = null;
this.deleteButtonEl = null;
this.modeButtonsEls = null;
this.dateGallery = null;
this.modeSegmentedButton = null;
}
syncFromUrl = () => {
const config = this.readConfigFromUrl();
this.dateGallery.changeConfig(config);
};
// Writes the initial HTML into the uiloos-calendar element
writeHTML() {
this.innerHTML = `
<div class="calendar-example">
<dialog>
<form class="calendar-event-form">
<b class="calendar-event-form-title">Edit event</b>
<div class="calendar-event-form-field">
<label for="title">Title</label>
<input id="title" name="title" required />
</div>
<div class="calendar-event-form-field">
<label for="description">Description</label>
<textarea
id="description"
name="description"
cols="4"
rows="4"
required
></textarea>
</div>
<uiloos-daterangepicker
time
start-name="start"
end-name="end"
start-label="Start (mm/dd/yyyy hh:mm)"
end-label="End (mm/dd/yyyy hh:mm)"
start-error-label="Start"
end-error-label="End"
required
>
Loading...
</uiloos-daterangepicker>
<div class="calendar-event-form-field">
<label for="color">Color</label>
<input id="color" name="color" type="color" required />
</div>
<button type="submit">Save</button>
</form>
<button class="delete-event-button">Delete event</button>
</dialog>
<div class="calender-topbar">
<div class="calendar-mode">
<button class="mode-button" data-mode='month'>Year</button>
<button class="mode-button" data-mode='month-six-weeks'>Month</button>
<button class="mode-button" data-mode='week'>Week</button>
<button class="mode-button" data-mode='day'>Day</button>
</div>
<div class='calendar-controls'>
<button class="calendar-previous calendar-button" aria-label="previous">❮</button>
<span class="calendar-title">Loading...</span>
<button class="calendar-next calendar-button" aria-label="next">❯</button>
</div>
<div class="calendar-actions">
<button class="calendar-today calendar-button">Today</button>
<button class="calendar-add-event calendar-button">
+ Add event
</button>
</div>
</div>
<div class="calendar-wrapper">Loading...</div>
</div>
`;
}
// Gathers all buttons / forms and sets up what happens when you use them
registerInteractions() {
// Step 1: setup the topbar actions
this.querySelector('.calendar-next').onclick = () => {
this.dateGallery.next();
};
this.querySelector('.calendar-previous').onclick = () => {
this.dateGallery.previous();
};
this.querySelector('.calendar-today').onclick = () => {
this.dateGallery.today();
};
this.modeButtonsEls.forEach((button) => {
button.onclick = () => {
this.modeSegmentedButton.activate(button);
};
});
// Step 2: setup the actions that are about adding events.
this.querySelector('.calendar-add-event').onclick = () => {
// Mark the form as being for a new event.
this.formEvent = null;
this.openNewEventForm(new Date());
};
// Reset the form whenever it is closed, will be
// on submit and when esc or backdrop is clicked
this.addEventDialogEl.onclose = () => {
// Whenever the event is closed reset the form,
// since it is re-used.
this.calendarEventFormEl.reset();
// Set to null so we do not keep an event in memory,
this.formEvent = null;
this.deleteButtonEl.onclick = undefined;
};
// Close dialog when backdrop is clicked
this.addEventDialogEl.onclick = (event) => {
if (event.target.nodeName === 'DIALOG') {
this.addEventDialogEl.close();
}
};
// Create a new event, or edit the event based on whether or not
// there is a formEvent.
this.calendarEventFormEl.onsubmit = (event) => {
event.preventDefault();
const formData = new FormData(this.calendarEventFormEl);
const isCreating = this.formEvent === null;
if (isCreating) {
// Inform the dateGallery of the new event
this.dateGallery.addEvent({
data: {
id: eventId(),
title: formData.get('title'),
description: formData.get('description'),
color: formData.get('color'),
},
startDate: formData.get('start'),
endDate: formData.get('end'),
});
} else {
// Inform the dateGallery that the event has changed.
// First update the data object of the event, to whatever
// the user filled in.
this.formEvent.changeData({
id: this.formEvent.data.id,
title: formData.get('title'),
description: formData.get('description'),
color: formData.get('color'),
});
// Then tell the DateGallery that the event has actually moved
this.formEvent.move({
startDate: formData.get('start'),
endDate: formData.get('end'),
});
}
// Close the dialog, note that this causes the
// `addEventDialogEl.onclose` to fire.
this.addEventDialogEl.close();
};
}
// Activates a mode and syncs both the dateGallery and modeSegmentedButton (ActiveList)
// This way no matter how the mode changes, either from inside the dateGallery subscriber
// or via a button click on the mode segemented button they will always be in sync.
activateMode(mode) {
// Step 1: sync with the segmented button
// Find the button that represents the mode
const button = this.modeButtonsEls.find(
(button) => button.dataset.mode === mode
);
// and activate that button.
this.modeSegmentedButton.activate(button);
// Step 2: sync with the DateGallery
// When the 'mode' is month it means 'year' has been selected
// and we show 12 month calenders side by side.
if (mode === 'month') {
// Anchor date to january first, otherwise the 'year' will start
// at the current month.
const initialDate = new Date(this.dateGallery.firstFrame.anchorDate);
initialDate.setMonth(0);
initialDate.setDate(1);
this.dateGallery.changeConfig({
mode,
numberOfFrames: 12,
initialDate,
});
} else {
this.dateGallery.changeConfig({ mode, numberOfFrames: 1 });
}
}
// Opens the "event form" in "new" mode and sets the start date and time
// to the Date object provided.
openNewEventForm(startDate) {
const rangePickerEl = this.calendarEventFormEl.querySelector(
'uiloos-daterangepicker'
);
// Set the startDate to what was provided.
rangePickerEl.setAttribute('start-value', formatDateForInput(startDate));
// Set the endDate to the startDate plus one hour
const endDate = new Date(startDate);
endDate.setHours(startDate.getHours() + 1, startDate.getMinutes());
rangePickerEl.setAttribute('end-value', formatDateForInput(endDate));
this.calendarEventFormEl.querySelector('#color').value = '#9333ea';
this.addEventDialogEl.showModal();
this.calendarEventFormEl.querySelector(
'.calendar-event-form-title'
).textContent = 'Add event';
this.deleteButtonEl.style.display = 'none';
this.deleteButtonEl.onclick = () => undefined;
}
// Opens the "event form" in "edit" mode and sets all input fields to the
// provided event.
openEditEventForm(event) {
this.formEvent = event;
this.calendarEventFormEl.querySelector('#title').value = event.data.title;
this.calendarEventFormEl.querySelector('#description').value =
event.data.description;
this.calendarEventFormEl.querySelector('#color').value = event.data.color;
const rangePickerEl = this.calendarEventFormEl.querySelector(
'uiloos-daterangepicker'
);
rangePickerEl.setAttribute(
'start-value',
formatDateForInput(event.startDate)
);
rangePickerEl.setAttribute('end-value', formatDateForInput(event.endDate));
this.addEventDialogEl.showModal();
this.calendarEventFormEl.querySelector(
'.calendar-event-form-title'
).textContent = 'Edit event';
this.deleteButtonEl.style.display = 'block';
this.deleteButtonEl.onclick = () => {
this.formEvent.remove();
// Close the dialog, note that this causes the
// `addEventDialogEl.onclose` to fire.
this.addEventDialogEl.close();
};
}
readConfigFromUrl() {
const url = new URL(window.location.href);
let mode = url.searchParams.get('mode') ?? 'month-six-weeks';
if (
!['month', 'month-six-weeks', 'week', 'day'].includes(mode.toLowerCase())
) {
mode = 'month-six-weeks';
}
const numberOfFrames = mode === 'month' ? 12 : 1;
let initialDate = url.searchParams.get('initialDate') ?? new Date();
initialDate = new Date(initialDate);
if (!isValid(initialDate)) {
initialDate = new Date();
}
return {
mode,
numberOfFrames,
initialDate,
};
}
}
customElements.define('uiloos-calendar', Calendar);
// Will contain information when event is dragged.
const dayDragData = {
xAtDragStart: 0,
dragStartTime: 0,
dragEndTime: 0,
};
function renderDayCalendar(calendar, dateGallery) {
const calendarDayTemplate = calendar.querySelector('#calendar-day-template');
const eventTemplate = calendar.querySelector('#calendar-day-event-template');
const dayDate = dateGallery.firstFrame.anchorDate;
calendar.calendarTitleEl.textContent = dateFormatter.format(dayDate);
const dayCalendarEl = clone(calendarDayTemplate);
const gridEl = dayCalendarEl.querySelector('.calendar-day-grid');
gridEl.style.setProperty('--width',DAY_WIDTH);
const eventRows = calculateEventRows(dateGallery);
// Render top bar hours
renderHours(calendar, dateGallery, gridEl, eventRows);
// Render current hour vertical red line, but only when today is shown
renderCurrentHour(calendar, dateGallery, gridEl, eventRows);
dateGallery.firstFrame.events.forEach((event) => {
const eventEl = clone(eventTemplate);
eventEl.style.backgroundColor = event.data.color;
const buttonEl = eventEl.querySelector('button');
buttonEl.style.backgroundColor = event.data.color;
buttonEl.style.color = yiq(event.data.color);
buttonEl.ariaLabel = ariaLabelForEvent(event);
const titleEl = eventEl.querySelector('i');
titleEl.title = event.data.title;
titleEl.textContent = event.data.title;
const [startTimeEl, endTimeEl] = Array.from(eventEl.querySelectorAll('b'));
const [startTimeDragEl, endTimeDragEl] = Array.from(
eventEl.querySelectorAll('.drag-indicator')
);
if (event.spansMultipleDays) {
if (dateGallery.isSameDay(event.startDate, dayDate)) {
// When the event starts on this day, make it span the
// entire day, as we know it does not end on this day.
const start = getMinutesSinceStart(event.startDate, DAY_START_HOUR);
eventEl.style.gridColumn = `${start + 1} / ${DAY_WIDTH}`;
// No end time indicator as it is on another day
endTimeDragEl.draggable = false;
// Show start time only on first day
startTimeEl.textContent = timeFormatter.format(event.startDate);
} else if (dateGallery.isSameDay(event.endDate, dayDate)) {
// When the event ends on this day start it at midnight, since
// we know it started on a previous day.
const end = getMinutesSinceStart(event.endDate, DAY_START_HOUR);
eventEl.style.gridColumn = `1 / ${end + 2}`;
// No start time drag indicator as it is on another day
startTimeDragEl.draggable = false;
// Show end time only on last day
endTimeEl.textContent = timeFormatter.format(event.endDate);
} else {
// When the event is during this whole day
eventEl.style.gridColumn = `1 / ${DAY_WIDTH}`;
// No start / end drag indicator as it is on another day
startTimeDragEl.draggable = false;
endTimeDragEl.draggable = false;
}
} else {
// The event is contained within this day.
const start = getMinutesSinceStart(event.startDate, DAY_START_HOUR);
const end = getMinutesSinceStart(event.endDate, DAY_START_HOUR);
eventEl.style.gridColumn = `${start + 1} / ${end + 1}`;
// When fully in this day show both times
startTimeEl.textContent = timeFormatter.format(event.startDate);
endTimeEl.textContent = timeFormatter.format(event.endDate);
}
eventEl.style.gridRow = eventRows.get(event);
// When clicking on an event open the "event form"
// and prefill it with the clicked event.
eventEl.onclick = (e) => {
e.stopPropagation();
calendar.openEditEventForm(event);
};
// An event is draggable when it can be fitted on this day
eventEl.draggable = event.spansMultipleDays === false;
// When the drag starts store information about which event is dragged
function onDragStart(e) {
e.stopPropagation();
// Store what the mouse position was at the start of the drag.
// Used to calulate how many minutes the user wants the event
// to move.
dayDragData.xAtDragStart = e.clientX;
// Set store the original start and end time for when
// the dragging began. This way we always know the
// original times even after we "move" the event.
dayDragData.dragStartTime = new Date(event.startDate).getTime();
dayDragData.dragEndTime = new Date(event.endDate).getTime();
// Set the drag image to an empty image. Because we are
// going to continuously "move" the event we do not need
// a "ghost".
e.dataTransfer.setDragImage(emptyImage, 0, 0);
}
// When the the event is dragged alter the time period of the vent.
function onDrag(e) {
e.stopPropagation();
// Sometimes the clientX is suddenly zero on drag end,
// do nothing if this is the case. Otherwise the event
// will suddenly jump to the previous day
if (e.clientX === 0) {
return;
}
// The number of minutes moved is the amount of pixels away
// the cursor (clientX) is from the clientX at the start of
// the drag start.
const minutesMoved = e.clientX - dayDragData.xAtDragStart;
// Move by an increment of 5 minutes, to create a snap effect
if (minutesMoved % 5 === 0) {
// Date constructor wants milliseconds since 1970
const msMoved = minutesMoved * 60 * 1000;
// Note: moving the event will cause the entire DOM to be ripped
// to shreds and be rebuilt. So the 5 minute snap effect is
// also a performance boost.
if (e.target === eventEl) {
// Move both start and end times with the same values
// so the duration of the event stays the same.
event.move({
startDate: new Date(dayDragData.dragStartTime + msMoved),
endDate: new Date(dayDragData.dragEndTime + msMoved),
});
} else if (e.target === startTimeDragEl) {
// Move only the start time
event.move({
startDate: new Date(dayDragData.dragStartTime + msMoved),
endDate: event.endDate,
});
} else {
// Move only the end time
event.move({
startDate: event.startDate,
endDate: new Date(dayDragData.dragEndTime + msMoved),
});
}
}
}
eventEl.ondragstart = onDragStart;
startTimeDragEl.ondragstart = onDragStart;
endTimeDragEl.ondragstart = onDragStart;
eventEl.ondrag = onDrag;
startTimeDragEl.ondrag = onDrag;
endTimeDragEl.ondrag = onDrag;
gridEl.appendChild(eventEl);
});
calendar.calendarWrapperEl.appendChild(dayCalendarEl);
}
function writeDayTemplates(calendar) {
calendar.innerHTML += `
<template id="calendar-day-template">
<div class="calendar-day">
<ul class="calendar-day-grid"></ul>
</div>
</template>
<template id="calendar-day-event-template">
<li class="calendar-day-event">
<button>
<span class="drag-indicator" draggable="true"></span>
<span class="text-container">
<b></b>
<i></i>
<b class="end-time"></b>
</span>
<span class="drag-indicator" draggable="true"></span>
</button>
</li>
</template>
`;
}
/*
Takes a DateGallery and returns a Map to which the events
are keys, and the values are CSS gridRow strings.
For example:
{eventA: '1 / 2', eventB: '2 / 3', eventC: '3 / 4'}
The events are packed as tight as possible so the least
amount of rows are used.
*/
function calculateEventRows(dateGallery) {
// Step one: we place all events into the least amount of rows
const rows = packEventsOnAxis(dateGallery.firstFrame.events);
// Step two: we take the rows array and turn it into a Map of CSS
// grid row strings.
const eventRows = new Map();
dateGallery.firstFrame.events.forEach((event) => {
const row = rows.findIndex((row) => row.includes(event));
// Finally we know where to place the event, but we need to
// account for the fact that CSS grid starts counting at one
// and not zero. So we +1 the rows. Also we now that the first
// row shows the hours so another +1 is needed.
eventRows.set(event, `${row + 2}`);
});
return eventRows;
}
function renderHours(calendar, dateGallery, gridEl, eventRows) {
// Render the hours on the top
for (let i = DAY_START_HOUR; i < DAY_END_HOUR; i++) {
const hourEl = document.createElement('li');
hourEl.className = 'calendar-day-hour';
hourEl.ariaHidden = true;
const column = (i - DAY_START_HOUR) * 60 + 1;
hourEl.style.gridColumn = `${column} / ${column + 60}`;
hourEl.style.gridRow = `1 / ${getNoRows(eventRows)}`;
const date = new Date(dateGallery.firstFrame.anchorDate);
date.setHours(i, 0, 0, 0);
const time = timeFormatter.format(date);
hourEl.textContent = time;
// When clicking on a hour open the "event form" with the
// clicked hour selected.
hourEl.onclick = (event) => {
// To determine the minute we look to where the user
// clicked in the hour cell. Remember: the hour cell
// is 60px in height, one pixel per minute.
const rect = hourEl.getBoundingClientRect();
const distanceInMinutes = event.clientX - rect.left;
// Round to closest 5 minutes
const minute = Math.round(distanceInMinutes / 5) * 5;
const eventDate = new Date(date);
eventDate.setHours(date.getHours(), minute);
calendar.openNewEventForm(eventDate);
};
gridEl.appendChild(hourEl);
}
}
function renderCurrentHour(calendar, dateGallery, gridEl, eventRows) {
// Add a red line showing the current time, but only
// when showing today
if (dateGallery.firstFrame.dates[0].isToday) {
const currentHourEl = document.createElement('div');
currentHourEl.className = 'calendar-day-current-time';
currentHourEl.style.gridRow = `1 / ${getNoRows(eventRows)}`;
function update() {
const now = new Date();
const column = getMinutesSinceStart(now, DAY_START_HOUR);
currentHourEl.style.gridColumn = `${column + 1} / ${column + 2}`;
currentHourEl.setAttribute('time', timeFormatter.format(now));
}
update();
// Update the position of the red line every second
const id = setInterval(update, 1000);
// Register this interval to the calendar, so the calendar
// can remove the interval when the mode changes.
calendar.intervalsIds.push(id);
gridEl.append(currentHourEl);
}
}
function getNoRows(eventRows) {
let noRows = 0;
eventRows.forEach((x) => {
noRows = Math.max(parseInt(x, 10), noRows);
});
return noRows < 20 ? 20 : noRows;
}
function formatDateForInput(date) {
const time = timeFormatter.format(date);
return `${dateFormatter.format(date)} ${time}`;
}
// Packs all events on an axis (row / column) as tightly as possible
// with the least amount of rows / columns needed.
function packEventsOnAxis(events) {
// Note: the code below uses the term columns / column for clarity
// but it also works for rows.
// Step one: we place all events into the least amount of columns
const columns = [[]];
events.forEach((event) => {
// Shortcut if the event does not overlap we can
// safely place it in the first column.
if (!event.isOverlapping) {
columns[0].push(event);
return;
}
// Find the first column we do not have overlapping events in,
// since that is the place the event can fit into. By finding
// the first column it fits into we make sure we use as little
// columns as possible.
const column = columns.find((column) => {
return column.every(
(otherEvent) => !event.overlappingEvents.includes(otherEvent)
);
});
if (column) {
// If we find a columm, great add the event to it.
column.push(event);
} else {
// If we cannot find a column the event fits into
// we create a new column.
columns.push([event]);
}
});
return columns;
}
function ariaLabelForEvent(event) {
const start = dateTimeFormatter.format(event.startDate);
const end = dateTimeFormatter.format(event.endDate);
return `Edit event titled: '${event.data.title}', which starts on ${start} and ends on ${end}`;
}
function generateEvents() {
const currentYear = new Date().getFullYear();
const events = [];
for (const year of [currentYear - 1, currentYear, currentYear + 1]) {
for (let i = 1; i < 13; i++) {
const month = i > 9 ? i : '0' + i;
events.push({
data: {
id: eventId(),
title: 'Smith',
description: "Business meeting",
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-01T10:30:00`),
endDate: new Date(`${year}-${month}-01T16:15:00`),
});
events.push({
data: {
id: eventId(),
title: 'Smith',
description: "Business meeting",
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-02T10:00:00`),
endDate: new Date(`${year}-${month}-02T14:45:00`),
});
events.push({
data: {
id: eventId(),
title: 'Steel',
description: "Dentist appointment",
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-04T09:00:00`),
endDate: new Date(`${year}-${month}-04T17:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Steel',
description: "Dentist appointment",
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-07T15:00:00`),
endDate: new Date(`${year}-${month}-07T17:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Smith',
description: "Business meeting",
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-07T09:00:00`),
endDate: new Date(`${year}-${month}-07T15:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Rose',
description: "Trip to the museum",
color: '#f97316',
},
startDate: new Date(`${year}-${month}-10T10:00:00`),
endDate: new Date(`${year}-${month}-10T15:30:00`),
});
events.push({
data: {
id: eventId(),
title: 'York',
description: "Discussing work",
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-12T10:00:00`),
endDate: new Date(`${year}-${month}-12T16:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
description: "Planning project",
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-15T09:00:00`),
endDate: new Date(`${year}-${month}-15T14:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
description: "Planning project",
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-17T12:00:00`),
endDate: new Date(`${year}-${month}-17T15:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
description: "Planning project",
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-18T12:00:00`),
endDate: new Date(`${year}-${month}-18T15:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'York',
description: "Discussing work",
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-19T10:00:00`),
endDate: new Date(`${year}-${month}-19T16:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'York',
description: "Discussing work",
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-20T11:00:00`),
endDate: new Date(`${year}-${month}-20T17:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Rose',
description: "Trip to the museum",
color: '#f97316',
},
startDate: new Date(`${year}-${month}-23T10:00:00`),
endDate: new Date(`${year}-${month}-23T15:30:00`),
});
events.push({
data: {
id: eventId(),
title: 'Croft',
description: "Meeting with Croft",
color: '#10b981',
},
startDate: new Date(`${year}-${month}-25T12:00:00`),
endDate: new Date(`${year}-${month}-25T13:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Rose',
description: "Trip to the museum",
color: '#f97316',
},
startDate: new Date(`${year}-${month}-28T10:00:00`),
endDate: new Date(`${year}-${month}-28T13:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
description: "Planning project",
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-25T13:00:00`),
endDate: new Date(`${year}-${month}-25T17:00:00`),
});
}
}
return events;
}
function eventId() {
id++;
return id;
}
// Will contain the event that is dragged
let monthDraggedEvent = null;
function renderMonthCalendar(calendar, dateGallery) {
const calendarMonthTemplate = calendar.querySelector(
'#calendar-month-template'
);
const calendarDayTemplate = calendar.querySelector(
'#calendar-month-daycell-template'
);
const eventTemplate = calendar.querySelector(
'#calendar-month-event-template'
);
calendar.calendarTitleEl.textContent = monthAndYearFormatter.format(
dateGallery.firstFrame.anchorDate
);
const calendarMonthEl = clone(calendarMonthTemplate);
const cellsEl = calendarMonthEl.querySelector('.calendar-month-daycells');
const eventPositionsByDay = calculateEventPositionByDay(dateGallery);
dateGallery.firstFrame.dates.forEach((dateObj, index) => {
const eventsForDay = eventPositionsByDay[index];
const dayEl = clone(calendarDayTemplate);
// Set the aria label of the button to something sensible
const date = new Date(dateObj.date);
// Set the date to the current hour, and to the closest 5 minutes
const now = new Date();
date.setHours(now.getHours(), Math.round(now.getMinutes() / 5) * 5);
const formatted = dateTimeFormatter.format(date);
dayEl.ariaLabel = `Create new event at around ${formatted}`;
if (dateObj.isPadding) {
dayEl.classList.add('padding');
}
// When clicking on a day open the "event form" with the
// clicked date selected.
dayEl.onclick = () => {
calendar.openNewEventForm(date);
};
// Now set the number of the date in the right corner
const dayNumberEl = dayEl.querySelector('.calendar-month-daycell-number');
dayNumberEl.innerHTML = `
<time datetime="${dateObj.date.toISOString()}">
${dateObj.date.getDate()}
</time>
`;
dayNumberEl.onclick = (e) => {
e.stopPropagation();
dateGallery.changeConfig({
initialDate: dateObj.date,
mode: 'day',
numberOfFrames: 1,
});
};
const eventsEl = dayEl.querySelector('.calendar-month-daycell-events');
const noRows = eventsForDay.length;
eventsEl.style.gridTemplateRows = `repeat(${noRows}, 32px)`;
for (const event of dateObj.events) {
const eventEl = clone(eventTemplate);
// Needed for the ::before event dot
eventEl.style.setProperty('--color', event.data.color);
const buttonEl = eventEl.querySelector('button');
buttonEl.ariaLabel = ariaLabelForEvent(event);
const eventTitleEl = eventEl.querySelector('.calendar-month-event-title');
eventTitleEl.title = event.data.title;
const timeEl = eventEl.querySelector('.calendar-month-event-time');
if (event.spansMultipleDays) {
/*
Here we put an event on a specific row in the CSS grid
by doing this all event that are on multiple days are
neatly ordered within a week, without any gaps.
See the `calculateEventPositionByDay` function below to
see how this is calculated.
The +1 is needed because CSS Grid starts counting at 1.
*/
eventEl.style.gridRow = eventsForDay.indexOf(event) + 1;
eventEl.classList.add('multiple');
buttonEl.style.color = yiq(event.data.color);
eventEl.style.backgroundColor = event.data.color;
/*
An event that spans multiple days is rendered once in each
day the event occurs.
On the startDate we render the title and start time, on the
endDate we render the end time. For all days in between we
only give it a background color.
*/
if (dateGallery.isSameDay(event.startDate, dateObj.date)) {
// Adds a left border
eventEl.classList.add('first-day-of-event');
eventTitleEl.textContent = event.data.title;
timeEl.textContent = timeFormatter.format(event.startDate);
} else if (dateGallery.isSameDay(event.endDate, dateObj.date)) {
timeEl.textContent = timeFormatter.format(event.endDate);
}
} else {
// When an event happens on a single day show the title and start time.
eventTitleEl.textContent = event.data.title;
timeEl.textContent = timeFormatter.format(event.startDate);
}
// When clicking on an event open the "event form"
// and prefill it with the clicked event.
eventEl.onclick = (e) => {
e.stopPropagation();
calendar.openEditEventForm(event);
};
// All events are draggable.
eventEl.draggable = true;
// When the drag starts store which event is dragged
eventEl.ondragstart = (e) => {
e.stopPropagation();
// Set the drag image to an empty image. Because we are
// going to continuously "move" the event we do not need
// a "ghost".
e.dataTransfer.setDragImage(emptyImage, 0, 0);
monthDraggedEvent = event;
};
eventsEl.appendChild(eventEl);
}
// When the event is dragged over a day, set that day as the
// events startDate, but keep the original duration of the event.
dayEl.ondragover = (e) => {
e.stopPropagation();
// Create a new startDate based on the date that the event
// has been dragged over.
const startDate = new Date(date);
// Now copy the original start hours.
startDate.setHours(
monthDraggedEvent.startDate.getHours(),
monthDraggedEvent.startDate.getMinutes()
);
// Calculate the duration of the event.
const duration =
monthDraggedEvent.endDate.getTime() - monthDraggedEvent.startDate.getTime();
// Add the duration to the new startDate to get the endDate
const endDate = new Date(startDate.getTime() + duration);
monthDraggedEvent.move({
startDate,
endDate,
});
};
cellsEl.appendChild(dayEl);
});
calendar.calendarWrapperEl.appendChild(calendarMonthEl);
}
function writeMonthTemplates(calendar) {
calendar.innerHTML += `
<template id="calendar-month-template">
<div class="calendar-month">
<ul class="calendar-month-daynames">
<li class="calendar-month-dayname">Sun</li>
<li class="calendar-month-dayname">Mon</li>
<li class="calendar-month-dayname">Tue</li>
<li class="calendar-month-dayname">Wed</li>
<li class="calendar-month-dayname">Thu</li>
<li class="calendar-month-dayname">Fri</li>
<li class="calendar-month-dayname">Sat</li>
</ul>
<ul class="calendar-month-daycells"></ul>
</div>
</template>
<template id="calendar-month-daycell-template">
<li class="calendar-month-daycell" role="button">
<button class="calendar-month-daycell-number"></button>
<ul class="calendar-month-daycell-events">
</ul>
</li>
</template>
<template id="calendar-month-event-template">
<li class="calendar-month-event">
<button class="calendar-month-event-wrapper">
<span class="calendar-month-event-title"></span>
<span class="calendar-month-event-time"></span>
</button>
</li>
</template>
`;
}
/*
Takes a calendar and returns an array of arrays, each
subarray represents a day and contains all events of that
day.
The position / index of the event with the "day" array is
the "row" it should be put in the CSS Grid.
The events are packed as tight as possible so the least
amount of rows are used.
*/
function calculateEventPositionByDay(dateGallery) {
// Will contain an array for each day of the month
const month = [];
dateGallery.firstFrame.dates.forEach((dateObj, index) => {
// Will contain all events within the day.
const day = [];
const prevDay = month[index - 1];
dateObj.events.forEach((event) => {
if (!event.spansMultipleDays) {
return;
}
// If there is a previous day, meaning it is not the
// first day of the displayed month calendar
if (prevDay) {
// Try finding the event within the previous day
const index = prevDay.indexOf(event);
// If the event exists add it on this day at the same index / row
// as the day before, this makes an event appear on the same
// row for multiple days which is visually pleasing.
if (index !== -1) {
day[index] = event;
return;
}
}
let firstEmptyIndex = 0;
// Find the first empty position within the `day` array.
// This way we find the first empty row and fill it, this
// makes sure the events are packed close together.
while (day[firstEmptyIndex]) {
firstEmptyIndex++;
}
day[firstEmptyIndex] = event;
});
month.push(day);
});
return month;
}
function clone(template) {
return template.content.cloneNode(true).firstElementChild;
}
// Based on the background color given, it will return
// whether or not the text color should be black or white.
function yiq(backgroundColorHex) {
const r = parseInt(backgroundColorHex.substr(1, 2), 16);
const g = parseInt(backgroundColorHex.substr(3, 2), 16);
const b = parseInt(backgroundColorHex.substr(5, 2), 16);
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 128 ? 'black' : 'white';
}
// When given a date it returns the number of minutes
// that have passed since midnight. For example if the time
// was 12:30 you would get 12 * 60 + 30 = 750 minutes.
function minutesSinceMidnight(date) {
return date.getHours() * 60 + date.getMinutes();
}
// When given a date it returns the number of minutes
// that have passed since the startHour.
function getMinutesSinceStart(date, startHour) {
const midnight = minutesSinceMidnight(date);
// Since we start on 09:00 hours we need to treat 09:00 hours
// as the starting point, so shift the minutes back by 09:00
// hours.
const minutesSinceStart = midnight - startHour * 60;
// If the start time lied before the startHour, place it on
// the start.
if (minutesSinceStart < 0) {
return 0;
}
return minutesSinceStart;
}
// An empty image used when dragging of an event, so
// no drag ghost appears
const emptyImage = document.createElement('img');
// Set the src to be a 0x0 gif
emptyImage.src =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
// Will contain information when event is dragged.
const weekDragData = {
yAtDragStart: 0,
dragStartTime: 0,
dragEndTime: 0,
};
function renderWeekCalendar(calendar, dateGallery) {
const calendarWeekTemplate = calendar.querySelector(
'#calendar-week-template'
);
const eventTemplate = calendar.querySelector('#calendar-week-event-template');
calendar.calendarTitleEl.textContent = monthAndYearFormatter.format(
dateGallery.firstFrame.anchorDate
);
const weekEl = clone(calendarWeekTemplate);
const gridEl = weekEl.querySelector('.calendar-week-grid');
gridEl.style.setProperty('--height', WEEK_HEIGHT);
renderLeftHours(gridEl);
dateGallery.firstFrame.dates.forEach((dateObj, index) => {
const eventColumns = calculateEventColumns(dateObj);
const dayNameEl = document.createElement('button');
dayNameEl.className = 'calendar-week-dayname';
dayNameEl.innerHTML = `
<time datetime="${dateObj.date.toISOString()}">
${weekDayFormatter.format(dateObj.date)}
</time>
`;
dayNameEl.style.gridColumn = index + 2;
// When clicking on a day open the "event form" with the
// clicked date selected.
dayNameEl.onclick = (e) => {
e.preventDefault();
dateGallery.changeConfig({
initialDate: dateObj.date,
mode: 'day',
numberOfFrames: 1,
});
};
gridEl.appendChild(dayNameEl);
// Will be a subgrid for each day containing the events
const dayEl = document.createElement('ul');
// Store the date of this element, for event drag and drop
dayEl.dataset.date = dateObj.date.getTime();
// Create sub grid for each day in the week.
dayEl.className = 'calendar-week-day-grid';
dayEl.style.gridColumn = dateObj.date.getDay() + 2;
dateObj.events.forEach((event) => {
const eventEl = clone(eventTemplate);
eventEl.style.backgroundColor = event.data.color;
const buttonEl = eventEl.querySelector('button');
buttonEl.style.backgroundColor = event.data.color;
buttonEl.style.color = yiq(event.data.color);
buttonEl.ariaLabel = ariaLabelForEvent(event);
const titleEl = eventEl.querySelector('i');
titleEl.title = event.data.title;
titleEl.textContent = event.data.title;
const [startTimeEl, endTimeEl] = Array.from(
eventEl.querySelectorAll('b')
);
eventEl.querySelector('.event-description').textContent =
event.data.description;
const [startTimeDragEl, endTimeDragEl] = Array.from(
eventEl.querySelectorAll('.drag-indicator')
);
/*
An event that spans multiple days is rendered once in each
day the event occurs.
On dates that match the startDate and endDate we make draggable.
*/
if (event.spansMultipleDays) {
if (dateGallery.isSameDay(event.startDate, dateObj.date)) {
// When the event starts on this day, make it span the
// entire day, as we know it does not end on this day.
const start = getMinutesSinceStart(event.startDate, WEEK_START_HOUR);
eventEl.style.gridRow = `${start + 1} / ${WEEK_HEIGHT}`;
// No end time indicator as it is on another day
endTimeDragEl.draggable = false;
// Show start time only on first day
startTimeEl.textContent = timeFormatter.format(event.startDate);
} else if (dateGallery.isSameDay(event.endDate, dateObj.date)) {
// When the event ends on this day start it at midnight, since
// we know it started on a previous day.
const end = getMinutesSinceStart(event.endDate, WEEK_START_HOUR);
eventEl.style.gridRow = `1 / ${end + 2}`;
// No start time drag indicator as it is on another day
startTimeDragEl.draggable = false;
// Show end time only on last day
endTimeEl.textContent = timeFormatter.format(event.endDate);
} else {
// When the event is during this whole day take up all space
eventEl.style.gridRow = `1 / ${WEEK_HEIGHT}`;
// No start / end drag indicator as it is on another day
startTimeDragEl.draggable = false;
endTimeDragEl.draggable = false;
}
} else {
// The event is contained within this day.
const start = getMinutesSinceStart(event.startDate, WEEK_START_HOUR);
const end = getMinutesSinceStart(event.endDate, WEEK_START_HOUR);
eventEl.style.gridRow = `${start + 1} / ${end + 1}`;
// It has both start and end time drag, and additionaly
// the event can be dragged itself.
event.draggable = true;
// When fully in this day show both times
startTimeEl.textContent = timeFormatter.format(event.startDate);
endTimeEl.textContent = timeFormatter.format(event.endDate);
}
eventEl.style.gridColumn = eventColumns.get(event);
// When clicking on an event open the "event form"
// and prefill it with the clicked event.
eventEl.onclick = (e) => {
e.stopPropagation();
calendar.openEditEventForm(event);
};
// An event is draggable when it can be fitted on this day
eventEl.draggable = event.spansMultipleDays === false;
// When the drag starts store information about which event is dragged
function onDragStart(e) {
e.stopPropagation();
// Store what the mouse position was at the start of the drag.
// Used to calulate how many minutes the user wants the event
// to move.
weekDragData.yAtDragStart = e.clientY;
// Set store the original start and end time for when
// the dragging began. This way we always know the
// original times even after we "move" the event.
weekDragData.dragStartTime = new Date(event.startDate).getTime();
weekDragData.dragEndTime = new Date(event.endDate).getTime();
// Set the drag image to an empty image. Because we are
// going to continuously "move" the event we do not need
// a "ghost".
e.dataTransfer.setDragImage(emptyImage, 0, 0);
}
// When the the event is dragged alter the time period of the vent.
function onDrag(e) {
e.stopPropagation();
// Sometimes the clientX is suddenly zero on drag end,
// do nothing if this is the case. Otherwise the event
// will suddenly jump to the previous day
if (e.clientX === 0) {
return;
}
// The number of minutes moved is the amount of pixels away
// the cursor (clientY) is from the clientY at the start of
// the drag start.
const minutesMoved = e.clientY - weekDragData.yAtDragStart;
// Find the dayEl element the cursor is currently hovering
// over, if it has found one the date of the event must
// be changed.
const hoveredDayEl = document
.elementsFromPoint(e.clientX, e.clientY)
.find((element) => {
return element.classList.contains('calendar-week-day-grid');
});
// The user might mouse out of the calendar, in that case default
// to the current startDate
let movedToDate = event.startDate;
if (hoveredDayEl) {
movedToDate = new Date(parseInt(hoveredDayEl.dataset.date, 10));
}
// Move by an increment of 5 minutes, to create a snap effect
if (
minutesMoved % 5 === 0 ||
!dateGallery.isSameDay(movedToDate, event.startDate)
) {
// Date constructor wants milliseconds since 1970
const msMoved = minutesMoved * 60 * 1000;
// Note: moving the event will cause the entire DOM to be ripped
// to shreds and be rebuilt. So the 5 minute snap effect is
// also a performance boost.
if (e.target === eventEl) {
const duration = weekDragData.dragEndTime - weekDragData.dragStartTime;
// First update to the new start time
const startDate = new Date(weekDragData.dragStartTime + msMoved);
/*
Second update the start date.
We do this via a call to `setFullYear` with all date parts.
Setting them in separate calls like this:
startDate.setFullYear(movedToDate.getFullYear());
startDate.setMonth(movedToDate.getMonth());
startDate.setDate(movedToDate.getDate());
Could cause a very nasty bug were it could set the date to a non
existing date. But only for very few dates were the size of the
month differs from the month the date is moved into.
The bug can be triggered like so:
const d = new Date(2024, 30, 1); // 1/30/2024 - Feb 30th 2024
d.setFullYear(2025) // Date is now 1/30/2025
d.setMonth(2); // Date tries to be 2/30/2025, which doesn't exist, and rolls over to 3/1/2025
d.setDate(15); // Date is now 3/15/2025 instead of 2/15/2025 as expected
The above I credit to Marc Hughes see: https://github.com/Simon-Initiative/oli-torus/pull/4614
*/
startDate.setFullYear(
movedToDate.getFullYear(),
movedToDate.getMonth(),
movedToDate.getDate()
);
// Move both start and end times with the same values
// so the duration of the event stays the same.
event.move({
startDate: startDate,
endDate: new Date(startDate.getTime() + duration),
});
} else if (e.target === startTimeDragEl) {
// Move only the start time
event.move({
startDate: new Date(weekDragData.dragStartTime + msMoved),
endDate: event.endDate,
});
} else {
// Move only the end time
event.move({
startDate: event.startDate,
endDate: new Date(weekDragData.dragEndTime + msMoved),
});
}
}
}
eventEl.ondragstart = onDragStart;
startTimeDragEl.ondragstart = onDragStart;
endTimeDragEl.ondragstart = onDragStart;
eventEl.ondrag = onDrag;
startTimeDragEl.ondrag = onDrag;
endTimeDragEl.ondrag = onDrag;
dayEl.appendChild(eventEl);
});
// When clicking on a hour open the "event form" with the
// clicked hour selected.
dayEl.onclick = (event) => {
// To determine the minute we look to where the user
// clicked in the day cell. Remember: the day cell
// is HEIGHTpx in height, one pixel per minute.
const rect = dayEl.getBoundingClientRect();
const distanceInMinutes = event.clientY - rect.top;
const hour = Math.floor(distanceInMinutes / 60);
let minute = Math.round(distanceInMinutes % 60);
// Round to closest 5 minutes
minute = Math.round(minute / 5) * 5;
const date = new Date(dateObj.date);
date.setHours(hour, minute);
calendar.openNewEventForm(date);
};
gridEl.appendChild(dayEl);
});
calendar.calendarWrapperEl.appendChild(weekEl);
}
function renderLeftHours(gridEl) {
// Render the hours on the left
for (let i = WEEK_START_HOUR; i < WEEK_END_HOUR + 1; i++) {
const hourEl = document.createElement('span');
hourEl.className = 'calendar-week-hour';
hourEl.ariaHidden = true;
const row = (i - WEEK_START_HOUR) * 60 + 3;
hourEl.style.gridRow = `${row} / ${row + 60}`;
const date = new Date();
date.setHours(i, 0, 0, 0);
const time = timeFormatter.format(date);
hourEl.textContent = time;
gridEl.appendChild(hourEl);
}
}
function writeWeekTemplates(calendar) {
calendar.innerHTML += `
<template id="calendar-week-template">
<div class="calendar-week">
<div class="calendar-week-grid"></div>
</div>
</template>
<template id="calendar-week-event-template">
<li class="calendar-week-event">
<button>
<span class="drag-indicator" draggable="true"></span>
<span class="text-container">
<span class="inner-text-container">
<b></b>
<i></i>
<span class="event-description"></span>
</span>
<b class="end-time"></b>
</span>
<span class="drag-indicator" draggable="true"></span>
</button>
</li>
</template>
`;
}
/*
Takes a DateGalleryDate and returns a Map to which the events
are keys, and the values are CSS gridColumn strings.
For example:
{eventA: '1 / 2', eventB: '2 / 3', eventC: '3 / 4'}
The events are packed as tight as possible so the least
amount of columns are used.
Note: since we are using a CSS grid we do get one limitation:
you cannot always divide a CSS grid equally over multiple events.
This is because CSS grids cannot have varying columns / rows,
meaning you cannot make one row have three columns, and the other
row have two.
This is a problem for us: say you have a day with five events,
three of which are overlapping, and the other two overlap as well.
This means we end up with 3 columns total to hold the three
overlapping events, but then the other 2 events also need to be
divided over three columns.
In an ideal world we would be able to say: CSS Grid make those
two events take the same amount of space in the 3 columms.
Essentialy making the 2 events the same size, but unfortunately
CSS cannot do this.
So my solution is to make one of the two events take up 2/3 and
the other 1/3. Not ideal but it works
*/
function calculateEventColumns(dateObj) {
// Step one: we place all events into the least amount of columns
const columns = packEventsOnAxis(dateObj.events);
// Step two: we take the columns array and turn it into a Map of CSS
// grid column strings.
const eventColumns = new Map();
dateObj.events.forEach((event) => {
// Shortcut if the event does not overlap make it span
// all columns.
if (!event.isOverlapping) {
eventColumns.set(event, `1 / ${columns.length + 1}`);
return;
}
// The start column is the first column this event can be found in.
const startColumn = columns.findIndex((column) => column.includes(event));
// Now that we have found the start, we need to find the end in the
// remaining columns.
const remainingColumns = columns.slice(startColumn);
// The end column is the first column an overlapping event can be found in,
// since it has to share the column with that event.
let endColumn = remainingColumns.findIndex((column) =>
column.some((otherEvent) => event.overlappingEvents.includes(otherEvent))
);
// If we cannot find an endColumn it means it was already on the
// last column, so place it there.
if (endColumn === -1) {
endColumn = columns.length;
} else {
// If the endColumn can be found we need to add the startColumn
// to it since the remainingColumns start counting at 0 again,
// so we need to make up the difference.
endColumn += startColumn;
}
// Finally we know where to place the event, but we need to
// account for the fact that CSS grid starts counting at one
// and not zero. So we +1 the columns.
eventColumns.set(event, `${startColumn + 1} / ${endColumn + 1}`);
});
return eventColumns;
}
function renderYearCalendar(calendar, dateGallery) {
const calendarMonthTemplate = calendar.querySelector(
'#calendar-year-template'
);
const calendarDayTemplate = calendar.querySelector(
'#calendar-year-daycell-template'
);
calendar.calendarTitleEl.textContent = yearFormatter.format(
dateGallery.firstFrame.anchorDate
);
const wrapperEl = document.createElement('ul');
wrapperEl.className = 'calendar-year-months';
dateGallery.frames.forEach((frame) => {
const calendarMonthEl = clone(calendarMonthTemplate);
const monthNameEl = calendarMonthEl.querySelector(
'.calendar-year-monthname'
);
monthNameEl.textContent = monthFormatter.format(frame.anchorDate);
monthNameEl.onclick = () => {
dateGallery.changeConfig({
mode: 'month-six-weeks',
numberOfFrames: 1,
initialDate: frame.anchorDate,
});
};
const cellsEl = calendarMonthEl.querySelector('.calendar-year-daycells');
frame.dates.forEach((dateObj) => {
const dayEl = clone(calendarDayTemplate);
/*
Place the dayEl in the correct column, this is only needed
for the "month" mode because it starts at the first of the
month, which may be on an other day than the start of
the week.
The +1 is needed because CSS Grid starts counting at 1.
*/
dayEl.style.gridColumn = dateObj.date.getDay() + 1;
if (dateObj.hasEvents) {
dayEl.classList.add('has-event');
}
// When clicking on a day open the "event form" with the
// clicked date selected.
dayEl.onclick = (e) => {
e.preventDefault();
dateGallery.changeConfig({
initialDate: dateObj.date,
mode: 'day',
numberOfFrames: 1,
});
};
// Now set the number of the date in the right corner
const dayNumberEl = dayEl.querySelector('.calendar-year-daycell-number');
dayNumberEl.innerHTML = `
<time datetime="${dateObj.date.toISOString()}">
${dateObj.date.getDate()}
</time>
`;
cellsEl.appendChild(dayEl);
});
wrapperEl.appendChild(calendarMonthEl);
});
calendar.calendarWrapperEl.appendChild(wrapperEl);
}
function writeYearTemplates(calendar) {
calendar.innerHTML += `
<template id="calendar-year-template">
<li class="calendar-year">
<button class="calendar-year-monthname">December</button>
<ul class="calendar-year-daynames">
<li class="calendar-year-dayname"><abbr title="Sunday">S</abbr></li>
<li class="calendar-year-dayname"><abbr title="Monday">M</abbr></li>
<li class="calendar-year-dayname"><abbr title="Tuesday">T</abbr></li>
<li class="calendar-year-dayname"><abbr title="Wednesday">W</abbr></li>
<li class="calendar-year-dayname"><abbr title="Thursday">T</abbr></li>
<li class="calendar-year-dayname"><abbr title="Friday">F</abbr></li>
<li class="calendar-year-dayname"><abbr title="Saturday">S</abbr></li>
</ul>
<ul class="calendar-year-daycells"></ul>
</li>
</template>
<template id="calendar-year-daycell-template">
<li class="calendar-year-daycell">
<button class="calendar-year-daycell-number"></button>
</li>
</template>
`;
}
<div class="calendar-example">
<uiloos-calendar>Loading calendar...</uiloos-calendar>
</div>
/* Top of the calendar */
.calendar-example {
display: grid;
gap: 8px;
font-family: Arial, Helvetica, sans-serif;
}
.calendar-example dialog {
min-width: 300px;
}
.calendar-example button {
color: white;
background-color: #9333ea;
cursor: pointer;
}
.calendar-example button:hover {
background-color: #a855f7;
}
.calendar-example ul {
list-style: none;
}
.calendar-example .calender-topbar {
display: grid;
justify-content: center;
gap: 16px;
margin-bottom: 16px;
}
.calendar-actions,
.calendar-controls {
display: flex;
justify-content: center;
gap: 8px;
}
@media only screen and (min-width: 960px) {
.calendar-example .calender-topbar {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
grid-template-areas: 'left center right';
place-items: center;
margin-bottom: 16px;
}
.calendar-actions,
.calendar-controls {
display: flex;
align-items: center;
gap: 8px;
}
}
.calendar-example .calendar-mode {
display: flex;
justify-content: center;
}
.calendar-example .calendar-mode button {
padding: 8px;
}
.calendar-example .calendar-mode button.active {
background-color: #581c87;
}
.calendar-example .calendar-button {
border: 1px solid black;
padding: 4px;
}
.calendar-example .calendar-title {
font-size: 26px;
width: 200px;
text-align: center;
}
/* The add / edit event forms */
.calendar-example .calendar-event-form {
display: grid;
gap: 8px;
padding: 8px 0px;
}
.calendar-example .calendar-event-form input {
border: 1px solid black;
height: 32px;
padding: 4px;
}
.calendar-example .calendar-event-form textarea {
border: 1px solid black;
padding: 4px;
}
.calendar-example .calendar-event-form button[type='submit'] {
border: 1px solid black;
height: 32px;
}
.calendar-example .calendar-event-form .calendar-event-form-field {
display: grid;
min-height: 32px;
gap: 4px;
}
.calendar-example .delete-event-button {
color: white;
background-color: #dc2626;
width: 100%;
height: 32px;
}
/* The month mode styles */
.calendar-example .calendar-month-daynames {
display: grid;
grid-template-columns: repeat(7, minmax(32px, 1fr));
gap: 2px;
}
.calendar-example .calendar-month-dayname {
display: grid;
place-content: center;
height: 100px;
font-size: 22px;
}
.calendar-example .calendar-month-daycells {
display: grid;
grid-template-columns: repeat(7, minmax(32px, 1fr));
gap: 0;
}
.calendar-example .calendar-month-daycell {
display: grid;
gap: 4px;
align-content: start;
box-shadow: 0 0 0 1px black;
min-height: 120px;
font-size: 22px;
background-color: white;
color: black;
cursor: pointer;
padding-bottom: 8px;
overflow: hidden;
}
.dark .calendar-example .calendar-month-daycell {
background-color: black;
color: white;
box-shadow: 0 0 0 1px white;
}
.calendar-example .calendar-month-daycell.padding {
color: gray;
}
.calendar-example .calendar-month-daycell-number {
padding: 4px;
justify-self: left;
background-color: white;
color: black;
font-size: 22px;
color: inherit;
}
.dark .calendar-example .calendar-month-daycell-number {
background-color: black;
color: white;
}
@media only screen and (min-width: 960px) {
.calendar-example .calendar-month-daycell-number {
justify-self: right;
}
}
.calendar-example .calendar-month-daycell-events {
display: grid;
grid-template-rows: 32px;
gap: 4px;
height: 100%;
}
.calendar-example .calendar-month-event {
--color: #000000; /* color is set from js */
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
color: black;
background-color: white;
font-size: 16px;
min-height: 32px;
}
.dark .calendar-example .calendar-month-event {
background-color: black;
}
.calendar-example .calendar-month-event::before {
content: '';
border-radius: 25px;
background-color: var(--color);
width: 10px;
height: 10px;
margin-left: 4px;
}
.calendar-example .calendar-month-event.multiple {
margin-left: -1px;
}
.calendar-example .calendar-month-event.first-day-of-event {
border-left: 1px solid black;
}
.dark .calendar-example .calendar-month-event.first-day-of-event {
border-left: 1px solid white;
}
.calendar-example .calendar-month-event.multiple::before {
display: none;
}
.calendar-example .calendar-month-event-wrapper {
display: flex;
justify-content: space-between;
flex-grow: 1;
background-color: transparent;
padding: 0 4px;
color: black;
}
.dark .calendar-example .calendar-month-event-wrapper {
background-color: black;
color: white;
}
.calendar-example .calendar-month-event-title {
width: 75px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: left;
}
.dark .calendar-example .calendar-month-event-title {
background-color: black;
color: white;
}
/* The year mode styles */
.calendar-example .calendar-year-months {
display: grid;
place-content: center;
gap: 32px;
}
@media only screen and (min-width: 680px) {
.calendar-example .calendar-year-months {
display: grid;
place-content: center;
grid-template-columns: 1fr 1fr;
gap: 32px;
width: calc(320px * 2);
margin: auto;
}
}
@media only screen and (min-width: 990px) {
.calendar-example .calendar-year-months {
grid-template-columns: 1fr 1fr 1fr;
width: calc(320px * 3);
}
}
.calendar-example .calendar-year {
width: 320px;
}
.calendar-example .calendar-year-monthname {
width: 100%;
text-align: center;
padding: 16px 0px;
font-size: 22px;
background-color: white;
color: black;
}
.calendar-example .calendar-year-monthname:hover {
background-color: white;
color: black;
}
.dark .calendar-example .calendar-year-monthname {
background-color: black;
color: white;
}
.calendar-example .calendar-year-daynames {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 2px;
margin-bottom: 16px;
}
.calendar-example .calendar-year-dayname {
display: grid;
place-content: center;
height: 16px;
font-size: 22px;
}
.calendar-example .calendar-year-daycells {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0;
}
.calendar-example .calendar-year-daycell {
display: grid;
place-items: center;
height: 32px;
background-color: white;
color: black;
cursor: pointer;
font-size: 14px;
}
.dark .calendar-example .calendar-year-daycell {
background-color: black;
color: white;
}
.calendar-example .calendar-year-daycell .calendar-year-daycell-number {
color: black;
background-color: white;
}
.dark .calendar-example .calendar-year-daycell .calendar-year-daycell-number {
background-color: black;
color: white;
}
.calendar-example
.calendar-year-daycell.has-event
.calendar-year-daycell-number {
display: grid;
place-items: center;
background-color: black;
color: white;
border-radius: 100%;
padding: 4px;
font-weight: bold;
width: 26px;
height: 26px;
}
.dark
.calendar-example
.calendar-year-daycell.has-event
.calendar-year-daycell-number {
background-color: white;
color: black;
}
/* The week mode styles */
.calendar-example .calendar-week {
max-width: 100vw;
overflow: auto;
}
@media only screen and (min-width: 960px) {
.calendar-example .calendar-week {
width: 100%;
}
}
.calendar-example .calendar-week-grid {
/* Is set from within JavaScript based on the visible hours */
--height: 0;
display: grid;
grid-template-columns: 100px repeat(7, minmax(120px, 1fr));
grid-template-rows: 50px repeat(var(--height), 1px);
}
.calendar-example .calendar-week-dayname {
align-self: self-end;
position: sticky;
top: 0;
margin-bottom: 8px;
text-align: center;
font-size: 22px;
background-color: white;
z-index: 1;
}
.dark .calendar-example .calendar-week-dayname {
background-color: black;
color: white;
}
.calendar-example .calendar-week-hour {
grid-column: 1 / 9;
margin-top: -10px; /* Align the hour bar on the grid */
height: 60px;
margin-left: 42px;
}
.calendar-example .calendar-week-hour::after {
display: block;
content: ' ';
background-color: lightgray;
height: 1px;
width: calc(100% - 64px);
position: relative;
bottom: 10px;
left: 64px;
z-index: -1;
}
.calendar-example .calendar-week-day-grid {
grid-row: 2 / var(--height);
display: grid;
grid-template-rows: repeat(var(--height), 1px);
grid-auto-columns: 1fr;
grid-auto-flow: column;
border-right: 1px solid black;
column-gap: 8px;
padding: 0 4px;
cursor: pointer;
}
.calendar-example .calendar-week-event {
opacity: 0.9;
overflow: hidden;
text-align: left;
}
.calendar-example .calendar-week-event button {
text-align: left;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
width: 100%;
}
.calendar-example .calendar-week-event .text-container {
padding: 0 4px;
flex-grow: 1;
overflow: hidden;
display: grid;
}
.calendar-example .calendar-week-event .text-container .inner-text-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.calendar-example .calendar-week-event .text-container .end-time {
align-self: end;
}
.calendar-example .calendar-week-event .drag-indicator {
width: 100%;
height: 4px;
}
.calendar-example .calendar-week-event .drag-indicator[draggable='true'] {
cursor: row-resize;
}
/* The day modes styles */
.calendar-example .calendar-day {
max-width: 100vw;
overflow: auto;
}
@media only screen and (min-width: 960px) {
.calendar-example .calendar-day {
width: 100%;
}
}
.calendar-example .calendar-day-grid {
/* Is set from within JavaScript based on the visible hours */
--width: 0;
margin-top: 10px;
display: grid;
grid-template-columns: repeat(var(--width), minmax(1px, 1fr));
grid-template-rows: repeat(15, 30px);
row-gap: 8px;
}
.calendar-example .calendar-day-hour {
display: flex;
margin-left: -1px;
padding-left: 4px;
border-left: solid lightgray 1px;
cursor: pointer;
background-color: white;
font-size: 16px;
}
.dark .calendar-example .calendar-day-hour {
background-color: black;
color: white;
}
.calendar-example .calendar-day-current-time {
background-color: orangered;
width: 1px;
}
.calendar-example .calendar-day-current-time:hover::after {
content: attr(time);
display: flex;
justify-content: center;
align-items: center;
background-color: orangered;
width: 62px;
height: 32px;
}
.calendar-example .calendar-day-event {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
opacity: 0.9;
height: 30px;
overflow: hidden;
cursor: pointer;
z-index: 1;
}
.calendar-example .calendar-day-event button {
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.calendar-example .calendar-day-event .text-container {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
display: flex;
gap: 4px;
}
.calendar-example .calendar-day-event .end-time {
flex-grow: 1;
text-align: right;
}
.calendar-example .calendar-day-event .drag-indicator {
width: 4px;
height: 30px;
}
.calendar-example .calendar-day-event .drag-indicator[draggable='true'] {
cursor: col-resize;
}
const { ActiveList, createActiveListSubscriber } = require('@uiloos/core');
const galleryEl = document.querySelector('.image-gallery');
const buttons = Array.from(galleryEl.querySelectorAll('.gallery-items button'));
const dialogEl = galleryEl.querySelector('.gallery-dialog');
const activeImageEl = galleryEl.querySelector('.dialog-img-container');
const FADE_ANIMATION_DURATION = 400;
const gallery = new ActiveList(
{
contents: buttons,
isCircular: true,
},
createActiveListSubscriber({
onActivated(event, gallery) {
const button = gallery.lastActivated;
const imgEl = button.querySelector('picture');
activeImageEl.innerHTML = '';
activeImageEl.append(imgEl.cloneNode(true));
},
})
);
buttons.forEach((button) => {
button.onclick = () => {
gallery.activate(button);
dialogEl.addEventListener('keyup', keyup);
dialogEl.showModal();
};
});
galleryEl.querySelector('.next').onclick = () => {
gallery.activateNext();
};
galleryEl.querySelector('.previous').onclick = () => {
gallery.activatePrevious();
};
// Close dialog when backdrop is clicked
dialogEl.onclick = (event) => {
if (event.target.nodeName === 'DIALOG') {
dialogEl.classList.add('out');
setTimeout(() => {
dialogEl.classList.remove('out');
dialogEl.close();
}, FADE_ANIMATION_DURATION);
}
};
dialogEl.onclose = () => {
dialogEl.removeEventListener('keyup', keyup);
};
function keyup(event) {
if (event.key === 'ArrowLeft' || event.key === 'a') {
event.preventDefault();
gallery.activatePrevious();
} else if (event.key === 'ArrowRight' || event.key === 'd') {
event.preventDefault();
gallery.activateNext();
}
}
// Disable the carousel when users touches the carousel
let touchXStart = 0;
const SWIPE_DISTANCE = 100;
// Disable the carousel when users touches the carousel
dialogEl.addEventListener('touchstart', (event) => {
touchXStart = event.changedTouches[0].screenX;
});
dialogEl.addEventListener('touchmove', (event) => {
event.preventDefault();
const touchXCurrent = event.changedTouches[0].screenX;
const distance = touchXCurrent - touchXStart;
if (distance > SWIPE_DISTANCE) {
gallery.activateNext();
} else if (distance < -SWIPE_DISTANCE) {
gallery.activatePrevious();
}
});
<div class="image-gallery">
<dialog class="gallery-dialog">
<div class="dialog-img-container"></div>
<button class="previous" aria-label="previous image">←</button>
<button class="next" aria-label="next image">→</button>
</dialog>
<ul class="gallery-items">
<li>
<button>
<picture>
<source type="image/avif" srcset="/images/owls/snow.avif" />
<img
width="1920"
height="1280"
src="/images/owls/snow.jpg"
alt="image of a snow owl"
/>
</picture>
</button>
</li>
<li>
<button>
<picture>
<source type="image/avif" srcset="/images/owls/tawny.avif" />
<img
width="1920"
height="1280"
src="/images/owls/tawny.jpg"
alt="image of a tawny owl"
/>
</picture>
</button>
</li>
<li>
<button>
<picture>
<source type="image/avif" srcset="/images/owls/barn.avif" />
<img
width="1920"
height="1280"
src="/images/owls/barn.jpg"
alt="image of a barn owl"
/>
</picture>
</button>
</li>
<li>
<button>
<picture>
<source type="image/avif" srcset="/images/owls/burrowing.avif" />
<img
width="1920"
height="1280"
src="/images/owls/burrowing.jpg"
alt="image of a burrowing owl"
/>
</picture>
</button>
</li>
<li>
<button>
<picture>
<source type="image/avif" srcset="/images/owls/hawk.avif" />
<img
width="1920"
height="1280"
src="/images/owls/hawk.jpg"
alt="image of a northern hawk-owl"
/>
</picture>
</button>
</li>
<li>
<button>
<picture>
<source type="image/avif" srcset="/images/owls/white-faced.avif" />
<img
width="1920"
height="1280"
src="/images/owls/white-faced.jpg"
alt="image of a southern white faced owl"
/>
</picture>
</button>
</li>
</ul>
</div>
.image-gallery {
display: grid;
justify-content: center;
margin-bottom: 16px;
}
.image-gallery .gallery-selected {
margin-bottom: 16px;
max-width: 1200px;
}
.image-gallery ul {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.image-gallery li {
padding: 0;
margin: 0;
display: grid;
place-content: center;
}
.image-gallery button {
cursor: pointer;
}
.image-gallery button.next,
.image-gallery button.previous {
position: absolute;
top: calc(50% - 32px);
width: 64px;
height: 64px;
display: flex;
justify-content: center;
align-items: center;
color: black;
background-color: white;
font-size: 32px;
border: 1px white solid;
border-radius: 999999%;
}
.image-gallery button.next:hover,
.image-gallery button.previous:hover {
transition: all 0.8s;
border-color: black;
color: white;
background-color: black;
}
.image-gallery button.previous {
left: 0px;
}
.image-gallery button.next {
right: 0px;
}
.image-gallery img {
max-width: 100%;
height: auto;
}
.image-gallery dialog {
max-width: 960px;
padding: 32px;
background-color: transparent;
overflow: hidden;
}
.image-gallery dialog[open] {
animation: fade-in 0.4s ease-in;
}
.image-gallery dialog[open].out,
.image-gallery dialog[open].out::backdrop {
animation: fade-out 0.4s ease-out;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.image-gallery dialog::backdrop {
background-color: rgba(0, 0, 0, 0.4);
}
@media only screen and (max-width: 800px) {
.image-gallery ul {
grid-template-columns: repeat(2, 1fr);
}
.image-gallery .gallery-selected,
.image-gallery picture {
max-width: 320px;
min-width: 320px;
}
}
No more mail notifications
No more calendar notifications
No more chat notifications
import { ViewChannel, createViewChannelSubscriber } from '@uiloos/core';
const notificationCenterEl = document.querySelector('.notification-center');
const mailGroupEl = notificationCenterEl.querySelector('.mail');
const chatGroupEl = notificationCenterEl.querySelector('.chat');
const calendarGroupEl = notificationCenterEl.querySelector('.calendar');
const ANIMATION_DURATION = 200;
// 2000-01-01
export const isoFormatter = new Intl.DateTimeFormat('fr-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const notificationCenter = new ViewChannel(
{},
createViewChannelSubscriber({
onPresented(event) {
const view = event.view;
const groupEl = getGroupEl(view.data.application);
const notificationEl = document.createElement('li');
notificationEl.id = `notification-${view.data.id}`;
notificationEl.className = 'notification';
const topbarEl = groupEl.querySelector(
'.notification-group-topbar'
);
const openButtonEl = groupEl.querySelector(
'.notification-group-topbar-open'
);
topbarEl.onclick = () => {
if (groupEl.classList.contains('open')) {
groupEl.classList.remove('open');
openButtonEl.textContent = '›';
openButtonEl.ariaLabel = 'Open';
} else {
openButtonEl.textContent = '⌄';
openButtonEl.ariaLabel = 'Close';
groupEl.classList.add('open');
}
};
const contentEl = document.createElement('div');
contentEl.className = 'notification-content';
contentEl.innerHTML = `
<span class="notification-icon">✉</span>
<span>${view.data.title}</span>
${view.data.subtitle ? `<span>${view.data.subtitle}</span>` : ''}
`;
const deleteButtonEl = document.createElement('button');
deleteButtonEl.classList = 'notification-delete';
deleteButtonEl.ariaLabel = 'Delete';
deleteButtonEl.textContent = '×';
deleteButtonEl.onclick = () => {
view.dismiss();
};
notificationEl.append(contentEl, deleteButtonEl);
groupEl.querySelector('ol').append(notificationEl);
syncGroupEl(groupEl);
},
onDismissed(event) {
const notificationEl = notificationCenterEl.querySelector(
`#notification-${event.view.data.id}`
);
const groupEl = notificationEl.parentNode.parentNode;
notificationEl.classList.add('fade-out');
setTimeout(() => {
notificationEl.remove();
syncGroupEl(groupEl);
}, ANIMATION_DURATION);
},
})
);
function getGroupEl(application) {
if (application === 'mail') {
return mailGroupEl;
} else if (application === 'calendar') {
return calendarGroupEl;
} else {
return chatGroupEl;
}
}
// Helpers
function syncGroupEl(groupEl) {
const notificationsEl = groupEl.querySelector('ol');
groupEl.style.setProperty('--count', notificationsEl.children.length);
const children = Array.from(notificationsEl.children);
children.forEach((child, index) => {
child.style.setProperty('--offset', index);
// Show the first 5 notifications when stacked.
child.style.setProperty('--opacity', index > 4 ? 0 : 1);
});
const emptyMessageEl = groupEl.querySelector('.notification-group-empty');
if (children.length === 0) {
notificationsEl.style.display = 'none';
emptyMessageEl.style.display = 'block';
} else {
notificationsEl.style.display = 'block';
emptyMessageEl.style.display = 'none';
}
}
// Fill the notification center with dummy data
let id = 1;
notificationCenter.present({
priority: 0,
data: {
id: id++,
application: 'mail',
title: 'Project-details.pdf',
},
});
notificationCenter.present({
priority: 1,
data: {
id: id++,
application: 'mail',
title: 'Welcome to zombo.com',
},
});
notificationCenter.present({
priority: 0,
data: {
id: id++,
application: 'calendar',
title: 'Meeting with boss',
},
});
notificationCenter.present({
priority: 1,
data: {
id: id++,
application: 'calendar',
title: 'Sprint meeting',
},
});
notificationCenter.present({
priority: 2,
data: {
id: id++,
application: 'calendar',
title: 'Birthday party',
},
});
notificationCenter.present({
priority: 3,
data: {
id: id++,
application: 'calendar',
title: 'Briefing',
},
});
notificationCenter.present({
priority: 4,
data: {
id: id++,
application: 'calendar',
title: 'Coffee',
},
});
notificationCenter.present({
priority: 5,
data: {
id: id++,
application: 'calendar',
title: 'Workout',
},
});
notificationCenter.present({
priority: 0,
data: {
id: id++,
application: 'chat',
title: 'Hello',
subtitle: '- Bert',
},
});
notificationCenter.present({
priority: 1,
data: {
id: id++,
application: 'chat',
title: 'Sure can do!',
subtitle: '- Sarah',
},
});
notificationCenter.present({
priority: 2,
data: {
id: id++,
application: 'chat',
title: "Let's zoom about this instead",
subtitle: '- John',
},
});
setInterval(() => {
notificationCenter.present({
priority: 2,
data: {
id: id++,
application: 'chat',
title: 'Another message ' + id,
subtitle: '- Maarten',
},
});
}, 10000);
<div class="notification-center">
<div class="notification-group mail">
<div class="notification-group-topbar">
<b>Mail</b>
<button class="notification-group-topbar-open">›</button>
</div>
<p class="notification-group-empty">No more mail notifications</p>
<ol></ol>
</div>
<div class="notification-group calendar">
<div class="notification-group-topbar">
<b>Calendar</b>
<button class="notification-group-topbar-open">›</button>
</div>
<p class="notification-group-empty">No more calendar notifications</p>
<ol></ol>
</div>
<div class="notification-group chat">
<div class="notification-group-topbar">
<b>Chat</b>
<button class="notification-group-topbar-open">›</button>
</div>
<p class="notification-group-empty">No more chat notifications</p>
<ol></ol>
</div>
</div>
/* Note: --offset, --opacity and --count are set from JavaScript */
.notification-center {
display: grid;
gap: 28px;
max-width: 1000px;
margin: 0 auto 16px auto;
--notification-height: 42px;
--notification-gap: 5px;
}
.notification-center .notification-group ol {
display: grid;
width: 100%;
height: calc(var(--notification-height) + (2px * var(--count)));
perspective: 500px;
transform-style: preserve-3d;
transition: all 0.2s ease-in-out;
}
.notification-center .notification-group.open ol {
gap: var(--notification-gap);
height: calc(
((var(--notification-height) + var(--notification-gap)) * var(--count))
);
}
.notification-center .notification-group .notification-group-topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
cursor: pointer;
}
.notification-center .notification {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 42px;
border: black 1px solid;
color: black;
background-color: white;
padding: 0 4px;
transform: translateZ(calc(var(--offset) * -1px))
translateY(calc(var(--offset) * -35px))
scale(calc(1 + var(--offset) * -0.009));
filter: blur(calc(var(--offset) * 0.5px));
opacity: var(--opacity);
transition: all 0.2s ease-in-out;
}
.notification-center .notification-content {
display: flex;
align-items: center;
gap: 4px;
}
.notification-center .notification-icon {
font-size: 32px;
height: 42px;
display: flex;
margin-bottom: 12px;
}
.notification-center
.notification-group.open
ol
.notification:nth-child(n + 2) {
transform: translateY(var(--offset));
opacity: 1;
filter: none;
}
.notification-center .fade-out {
opacity: 0 !important;
}
.notification-center .notification-group-topbar-open {
font-size: 32px;
}
.notification-center .notification-delete {
font-size: 22px;
}
.notification-center .notification-group-empty {
margin: 0;
}