Files
aia-website/www/src/components/ImageCarousel.astro
T

94 lines
5.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
interface Slide { src: string; alt: string; caption: string }
interface Props { id: string; label: string; slides: Slide[] }
const { id, label, slides } = Astro.props;
---
<section class="image-carousel" id={id} aria-label={label} data-image-carousel>
<div class="image-carousel__stage">
{slides.map((slide, index) => (
<figure id={`${id}-slide-${index + 1}`} data-slide>
<img src={slide.src} alt={slide.alt} width="948" height="534" loading={index === 0 ? 'eager' : 'lazy'} />
<figcaption>{slide.caption}</figcaption>
</figure>
))}
<div data-carousel-controls hidden>
<button class="image-carousel__previous" type="button" aria-label="Previous slide" data-previous><span aria-hidden="true">❮</span></button>
<button class="image-carousel__next" type="button" aria-label="Next slide" data-next><span aria-hidden="true">❯</span></button>
</div>
</div>
<div data-carousel-controls hidden>
<div class="image-carousel__thumbnails" role="group" aria-label="Choose a tour photo">
{slides.map((slide, index) => (
<button type="button" data-thumbnail aria-label={`Show photo ${index + 1}: ${slide.caption}`} aria-controls={`${id}-slide-${index + 1}`}>
<img src={slide.src} alt="" width="80" height="80" loading="lazy" />
</button>
))}
</div>
<p class="sr-only" data-carousel-status role="status" aria-atomic="true"></p>
</div>
</section>
<script>
document.querySelectorAll<HTMLElement>('[data-image-carousel]').forEach(carousel => {
const slides = [...carousel.querySelectorAll<HTMLElement>('[data-slide]')];
const thumbnails = [...carousel.querySelectorAll<HTMLButtonElement>('[data-thumbnail]')];
const status = carousel.querySelector<HTMLElement>('[data-carousel-status]')!;
let current = 0;
const show = (index: number, announce = true) => {
current = (index + slides.length) % slides.length;
slides.forEach((slide, i) => {
slide.hidden = i !== current;
thumbnails[i].setAttribute('aria-pressed', String(i === current));
});
if (announce) status.textContent = `Photo ${current + 1} of ${slides.length}: ${slides[current].querySelector('figcaption')?.textContent}`;
};
slides.forEach((slide, index) => {
slide.setAttribute('role', 'group');
slide.setAttribute('aria-roledescription', 'slide');
slide.setAttribute('aria-label', `${index + 1} of ${slides.length}`);
});
carousel.setAttribute('aria-roledescription', 'carousel');
carousel.querySelector('[data-previous]')!.addEventListener('click', () => show(current - 1));
carousel.querySelector('[data-next]')!.addEventListener('click', () => show(current + 1));
thumbnails.forEach((button, index) => button.addEventListener('click', () => show(index)));
carousel.addEventListener('keydown', event => {
if (!(event.target instanceof HTMLButtonElement)) return;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault(); show(current + (event.key === 'ArrowRight' ? 1 : -1));
}
});
show(0, false);
carousel.dataset.enhanced = 'true';
carousel.querySelectorAll<HTMLElement>('[data-carousel-controls]').forEach(control => control.hidden = false);
});
</script>
<style>
.image-carousel { max-width: 1000px; margin: 2rem auto; }
.image-carousel__stage { position: relative; }
.image-carousel [data-carousel-status] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.image-carousel figure { position: relative; margin: 0 0 2rem; }
.image-carousel [hidden] { display: none !important; }
.image-carousel figure img { display: block; width: 100%; height: auto; margin: 0; }
.image-carousel figcaption { margin: 0; padding: .5rem .75rem; background: var(--color-primary); color: white; text-align: center; font-size: 1rem; line-height: 1.4; }
.image-carousel[data-enhanced] figure { margin-bottom: 1rem; }
.image-carousel[data-enhanced] figcaption { position: absolute; bottom: 0; width: 100%; background: rgb(37 64 128 / 90%); }
.image-carousel button { cursor: pointer; }
.image-carousel__previous, .image-carousel__next { position: absolute; top: 50%; transform: translateY(-50%); min-width: 44px; min-height: 44px; padding: 1rem; border: 0; background: rgb(255 255 255 / 95%); color: var(--color-primary); font-size: 2rem; line-height: 1; border-radius: 3px; }
.image-carousel__previous { left: 0; }
.image-carousel__next { right: 0; }
.image-carousel__previous:hover, .image-carousel__next:hover { background: var(--color-primary); color: white; }
.image-carousel button:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; box-shadow: 0 0 0 6px white; }
.image-carousel__thumbnails { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; padding: 6px; }
.image-carousel__thumbnails button { padding: 2px; border: 3px solid transparent; background: white; border-radius: 0; }
.image-carousel__thumbnails img { display: block; width: 80px; height: 80px; object-fit: cover; margin: 0; opacity: .6; }
.image-carousel__thumbnails button[aria-pressed="true"] { border-color: var(--color-primary); }
.image-carousel__thumbnails button:is(:hover, [aria-pressed="true"]) img { opacity: 1; }
@media (max-width: 479px) {
.image-carousel__previous, .image-carousel__next { padding: .75rem; font-size: 1rem; }
.image-carousel[data-enhanced] figcaption { position: static; font-size: .875rem; }
.image-carousel__previous, .image-carousel__next { top: 30%; }
}
/* No automatic rotation or animation: reduced-motion users get the same stable slides. */
</style>