character-ref/src/components/slider.webc
Sebin Nyshkim 71afaa324c fix: 💄 cut off box shadows of slider items
In order for box shadows to render as intended the slider needs to be able to fill the full width of the page content container. This also necessitates a different approach to layouting all other page content children and removes the need for a layouting container element
2025-07-09 02:31:50 +02:00

273 lines
6.7 KiB
Text

<script>
const slider = document.querySelector('.slider');
const track = slider.querySelector('.track');
const items = Array.from(slider.querySelectorAll('.track > *'));
const nav = slider.querySelector('.slider-nav');
const prevButton = slider.querySelector('button.prev');
const nextButton = slider.querySelector('button.next');
const TOLERANCE = 2;
const debounce = (fn, delay = 300) => {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(fn, args), delay);
};
};
const isAtStart = () => Math.floor(track.scrollLeft) <= TOLERANCE;
const isAtEnd = () =>
Math.abs(track.scrollLeft + track.offsetWidth - track.scrollWidth) <= TOLERANCE;
const updateAriaCurrent = (element, index) => {
currentIndex === index
? element.setAttribute('aria-current', 'true')
: element.removeAttribute('aria-current');
};
const updateActiveStates = () => {
items.forEach(updateAriaCurrent);
nav?.querySelectorAll('.indicator-btn').forEach(updateAriaCurrent);
prevButton.disabled = isAtStart();
nextButton.disabled = isAtEnd();
};
let currentIndex = 0;
const scrollToIndex = (index) => {
if (index < 0 || index >= items.length || index === currentIndex) return;
currentIndex = index;
items[index].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
updateActiveStates();
};
const prev = () => scrollToIndex(currentIndex - 1);
const next = () => scrollToIndex(currentIndex + 1);
const resizeObserver = new ResizeObserver(() => debounce(scrollToIndex(currentIndex)));
resizeObserver.observe(track);
const sliderObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.75) {
const index = items.indexOf(entry.target);
if (index !== -1) {
currentIndex = index;
updateActiveStates();
}
}
});
},
{ root: track, threshold: 0.75, rootMargin: '0px' }
);
const initializeGallery = () => {
items.forEach((el, i) => {
const btn = document.createElement('button');
if (i === 0) btn.setAttribute('aria-current', 'true');
btn.classList.add('indicator-btn');
btn.dataset.index = i;
btn.innerText = i + 1;
btn.setAttribute('aria-label', `Go to item ${i + 1} of ${items.length}`);
btn.addEventListener('click', () => scrollToIndex(Number(btn.dataset.index)));
if (i === 0) el.setAttribute('aria-current', 'true');
el.setAttribute('aria-label', `Item ${i + 1} of ${items.length}`);
el.setAttribute('tabindex', '0');
el.setAttribute('role', 'group');
nav?.appendChild(btn);
});
updateActiveStates();
};
items.forEach((el) => sliderObserver.observe(el));
prevButton.addEventListener('click', prev);
nextButton.addEventListener('click', next);
track.addEventListener('scroll', debounce(updateActiveStates, 100));
document.addEventListener('DOMContentLoaded', initializeGallery);
</script>
<section aria-label="Slider" webc:root="override">
<button class="prev" aria-label="Previous item">
<icon icon="fa6-solid:chevron-left"></icon>
</button>
<button class="next" aria-label="Next item">
<icon icon="fa6-solid:chevron-right"></icon>
</button>
<div class="track">
<slot></slot>
</div>
<nav webc:if="nav !== false" class="slider-nav" aria-label="Slider"></nav>
</section>
<style webc:scoped="slider">
:host {
display: grid;
grid-auto-columns: auto 1fr auto;
grid-template-areas:
'prev track next'
'. progress .';
align-items: center;
gap: 1em;
margin-block: var(--inbox-spacing);
}
:host :where(.prev, .next, .indicator-btn) {
--gradient-dir: to bottom right;
--gradient-start: var(--clr-box-gradient-start);
--gradient-end: var(--clr-box-gradient-end);
position: relative;
font-size: 1.25em;
color: var(--clr-text);
cursor: pointer;
background: linear-gradient(
var(--gradient-dir),
var(--gradient-start) 0%,
var(--gradient-end) 50%
);
box-shadow: 0.125em 0.125em 0.5em var(--clr-box-shadow);
border: none;
border-radius: 100%;
padding: 0.875em;
z-index: 1;
}
:host :where(.prev, .next, .indicator-btn):active::after {
--gradient-dir: to top left;
}
:host :where(.prev, .next, .indicator-btn)::after {
--gradient-dir: to bottom right;
--gradient-start: var(--clr-quick-info-gradient-start);
--gradient-end: var(--clr-quick-info-gradient-end);
content: '';
position: absolute;
inset: var(--border-thin);
background: linear-gradient(
var(--gradient-dir),
var(--gradient-start) 0%,
var(--gradient-end) 50%
);
border-radius: inherit;
z-index: -1;
}
:host :where(.prev, .next) {
--btn-alignment: 0.25em;
grid-row: track;
@media (min-width: 40em) {
--btn-alignment: calc(var(--inbox-spacing) + 1rem);
}
}
:host :where(.prev, .next):disabled {
cursor: not-allowed;
opacity: 0.5;
}
:host :where(.prev, .next) svg {
position: absolute;
inset: 0;
padding: 0.375em;
}
:host .prev {
grid-column: prev;
left: var(--btn-alignment);
}
:host .next {
grid-column: next;
right: var(--btn-alignment);
}
:host .track {
grid-row: track;
grid-column: prev / next;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
gap: 2em;
border-radius: 1em;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
}
:host .track > * {
scroll-snap-align: center;
padding-inline: var(--inbox-spacing);
}
:host .track::-webkit-scrollbar {
display: none;
}
:host .ref-image :first-child {
grid-row: image / caption;
}
:host .ref-image img {
width: 100%;
height: 80vw;
object-fit: cover;
}
:host .ref-image .caption {
font-size: 0.875em;
text-wrap: balance;
background: oklch(from var(--clr-box-background) l c h / 0.75);
padding-block: 0.5em;
padding-inline: 1em;
}
:host .slider-nav {
--indicator-size: 2.5em;
grid-row: progress;
grid-column: prev / next;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(1em, 100%), var(--indicator-size)));
gap: 0.5em;
place-content: center;
place-items: center;
font-size: 0.875em;
padding-inline: var(--inbox-spacing);
}
:host .indicator-btn {
font-size: 1em;
width: var(--indicator-size);
height: var(--indicator-size);
padding: 0;
aspect-ratio: 1 / 1;
}
:host .indicator-btn[aria-current='true']::after {
--gradient-dir: to top left;
}
</style>