feat: add animation
This commit is contained in:
parent
5e09ae35f0
commit
d154d2e937
2 changed files with 184 additions and 33 deletions
|
|
@ -12,6 +12,7 @@ interface Props {
|
||||||
const { images } = Astro.props;
|
const { images } = Astro.props;
|
||||||
|
|
||||||
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
const helpId = `${carouselId}-help`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|
@ -21,10 +22,11 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
aria-roledescription="carousel"
|
aria-roledescription="carousel"
|
||||||
aria-label="Image carousel"
|
aria-label="Image carousel"
|
||||||
data-carousel-id={carouselId}
|
data-carousel-id={carouselId}
|
||||||
|
tabindex="0"
|
||||||
|
aria-describedby={helpId}
|
||||||
>
|
>
|
||||||
<!-- Slides -->
|
<!-- Slides -->
|
||||||
<div class="relative h-0 pb-[56.25%]">
|
<div class="relative h-0 pb-[56.25%]">
|
||||||
<!-- 16:9 ratio placeholder -->
|
|
||||||
{
|
{
|
||||||
images.map((image, i) => (
|
images.map((image, i) => (
|
||||||
<img
|
<img
|
||||||
|
|
@ -73,6 +75,20 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
<span class="sr-only">Next</span>
|
<span class="sr-only">Next</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Theater toggle button (bottom-right) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute bottom-4 right-4 flex h-10 w-10 items-center justify-center
|
||||||
|
rounded-full bg-white/30 backdrop-blur-sm text-gray-800
|
||||||
|
hover:bg-white focus:outline-none focus-visible:ring-2
|
||||||
|
focus-visible:ring-indigo-600 transition-colors"
|
||||||
|
aria-label="Enter wide mode"
|
||||||
|
aria-pressed="false"
|
||||||
|
data-theater-toggle
|
||||||
|
>
|
||||||
|
<Icon name="mdi:arrow-expand-horizontal" class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Live region for screen‑reader announcements -->
|
<!-- Live region for screen‑reader announcements -->
|
||||||
<div
|
<div
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
|
|
@ -81,12 +97,15 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
data-live-announcer
|
data-live-announcer
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden helper text for screen readers -->
|
||||||
|
<p id={helpId} class="sr-only">
|
||||||
|
Carousel focused. Use Left and Right Arrow keys to change slides. Press T to
|
||||||
|
toggle wide mode. Press Escape to exit wide mode.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script define:vars={{ carouselId }}>
|
<script define:vars={{ carouselId }}>
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Carousel logic – runs once the component is in the DOM.
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const carousel = document.getElementById(carouselId);
|
const carousel = document.getElementById(carouselId);
|
||||||
if (!carousel) return;
|
if (!carousel) return;
|
||||||
|
|
@ -95,14 +114,49 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
const prevBtn = carousel.querySelector('[data-dir="prev"]');
|
const prevBtn = carousel.querySelector('[data-dir="prev"]');
|
||||||
const nextBtn = carousel.querySelector('[data-dir="next"]');
|
const nextBtn = carousel.querySelector('[data-dir="next"]');
|
||||||
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
|
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
|
||||||
|
const theaterBtn = carousel.querySelector("[data-theater-toggle]");
|
||||||
|
|
||||||
let current = 0;
|
let current = 0;
|
||||||
const total = slides.length;
|
const total = slides.length;
|
||||||
let autoRotateTimer;
|
let autoRotateTimer;
|
||||||
|
let theater = false;
|
||||||
|
|
||||||
|
const updateTheaterButton = () => {
|
||||||
|
if (!theaterBtn) return;
|
||||||
|
theaterBtn.setAttribute("aria-pressed", theater ? "true" : "false");
|
||||||
|
theaterBtn.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
theater ? "Exit wide mode" : "Enter wide mode",
|
||||||
|
);
|
||||||
|
const icon = theaterBtn.querySelector("svg");
|
||||||
|
if (icon) {
|
||||||
|
icon.setAttribute("data-icon-state", theater ? "collapse" : "expand");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTheaterState = () => {
|
||||||
|
const section = carousel.closest("[data-threecol]");
|
||||||
|
if (!section) return;
|
||||||
|
section.classList.toggle("theater-mode", theater);
|
||||||
|
updateTheaterButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheater = () => {
|
||||||
|
theater = !theater;
|
||||||
|
applyTheaterState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitTheater = () => {
|
||||||
|
if (!theater) return;
|
||||||
|
theater = false;
|
||||||
|
applyTheaterState();
|
||||||
|
};
|
||||||
|
|
||||||
|
theaterBtn?.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleTheater();
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Helper – show a slide by index (with fade transition)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
const showSlide = (index) => {
|
const showSlide = (index) => {
|
||||||
slides[current].classList.remove("opacity-100");
|
slides[current].classList.remove("opacity-100");
|
||||||
slides[current].classList.add("opacity-0");
|
slides[current].classList.add("opacity-0");
|
||||||
|
|
@ -112,15 +166,11 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
slides[current].classList.remove("opacity-0");
|
slides[current].classList.remove("opacity-0");
|
||||||
slides[current].classList.add("opacity-100");
|
slides[current].classList.add("opacity-100");
|
||||||
|
|
||||||
// Update screen‑reader announcement
|
|
||||||
if (liveAnnouncer) {
|
if (liveAnnouncer) {
|
||||||
liveAnnouncerContent = `Slide ${current + 1} of ${total}`;
|
liveAnnouncer.textContent = `Slide ${current + 1} of ${total}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Navigation button handlers
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
const goPrev = () => {
|
const goPrev = () => {
|
||||||
const prev = (current - 1 + total) % total;
|
const prev = (current - 1 + total) % total;
|
||||||
showSlide(prev);
|
showSlide(prev);
|
||||||
|
|
@ -133,24 +183,37 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
prevBtn?.addEventListener("click", goPrev);
|
prevBtn?.addEventListener("click", goPrev);
|
||||||
nextBtn?.addEventListener("click", goNext);
|
nextBtn?.addEventListener("click", goNext);
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// Key handling (now works when the section itself is focused)
|
||||||
// Keyboard navigation (left / right arrows)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
carousel.addEventListener("keydown", (e) => {
|
carousel.addEventListener("keydown", (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
const isTyping =
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable);
|
||||||
|
if (isTyping) return;
|
||||||
|
|
||||||
if (e.key === "ArrowLeft") {
|
if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
goPrev();
|
goPrev();
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
goNext();
|
goNext();
|
||||||
|
} else if (
|
||||||
|
(e.key === "t" || e.key === "T") &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleTheater();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
exitTheater();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Auto‑rotate (pause on hover / focus)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
const startAutoRotate = () => {
|
const startAutoRotate = () => {
|
||||||
stopAutoRotate(); // safety
|
stopAutoRotate();
|
||||||
autoRotateTimer = window.setInterval(goNext, 5000);
|
autoRotateTimer = window.setInterval(goNext, 5000);
|
||||||
};
|
};
|
||||||
const stopAutoRotate = () => {
|
const stopAutoRotate = () => {
|
||||||
|
|
@ -160,10 +223,8 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start rotating when the component mounts
|
|
||||||
startAutoRotate();
|
startAutoRotate();
|
||||||
|
|
||||||
// Pause when the user hovers or focuses inside the carousel
|
|
||||||
carousel.addEventListener("mouseenter", stopAutoRotate);
|
carousel.addEventListener("mouseenter", stopAutoRotate);
|
||||||
carousel.addEventListener("mouseleave", startAutoRotate);
|
carousel.addEventListener("mouseleave", startAutoRotate);
|
||||||
carousel.addEventListener("focusin", stopAutoRotate);
|
carousel.addEventListener("focusin", stopAutoRotate);
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,113 @@ const maxWidthClasses = {
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class={`${bgClass} ${py}`}>
|
<section class={`${bgClass} ${py}`} data-threecol>
|
||||||
<div
|
<div
|
||||||
class={`${maxWidthClasses[maxWidth]} mx-auto grid grid-cols-1 lg:grid-cols-[1fr_640px_1fr] items-center md:items-start gap-8 px-4 ${containerClass}`}
|
class={`threecol-row ${maxWidthClasses[maxWidth]} mx-auto flex flex-col lg:flex-row gap-8 px-4 ${containerClass}`}
|
||||||
|
>
|
||||||
|
<!-- Left Column -->
|
||||||
|
<div
|
||||||
|
class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"} flex-col`}
|
||||||
|
data-left
|
||||||
>
|
>
|
||||||
<!-- Left Column (Hidden on mobile, visible on desktop) -->
|
|
||||||
<div class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"}`}>
|
|
||||||
<slot name="left" />
|
<slot name="left" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center Column (Main content) -->
|
<!-- Center Column -->
|
||||||
<div class={`order-1 lg:order-2`}>
|
<div class={`order-1 lg:order-2 flex-col`} data-center>
|
||||||
<slot name="center" />
|
<slot name="center" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"}`}>
|
<div
|
||||||
|
class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"} flex-col`}
|
||||||
|
data-right
|
||||||
|
>
|
||||||
<slot name="right" />
|
<slot name="right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Base layout variables */
|
||||||
|
[data-threecol] .threecol-row {
|
||||||
|
--center-initial: 640px; /* initial center width at lg+ */
|
||||||
|
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure columns can shrink properly */
|
||||||
|
[data-threecol] [data-left],
|
||||||
|
[data-threecol] [data-center],
|
||||||
|
[data-threecol] [data-right] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large-screen flex behavior (mobile stays stacked naturally) */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-threecol] .threecol-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol] [data-left] {
|
||||||
|
max-width: 33.33%;
|
||||||
|
flex: 1 1 33.33%;
|
||||||
|
/* optional: transition flex changes if you expect subtle movement */
|
||||||
|
transition: flex 0.55s var(--transition-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol] [data-center] {
|
||||||
|
flex: 0 0 var(--center-initial);
|
||||||
|
transition:
|
||||||
|
flex-basis 0.55s var(--transition-ease),
|
||||||
|
flex-grow 0.55s var(--transition-ease),
|
||||||
|
max-width 0.55s var(--transition-ease);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol] [data-right] {
|
||||||
|
flex: 1 1 0;
|
||||||
|
transition:
|
||||||
|
flex-basis 0.55s var(--transition-ease),
|
||||||
|
flex-grow 0.55s var(--transition-ease),
|
||||||
|
opacity 0.35s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THEATER MODE (expanded center) */
|
||||||
|
[data-threecol].theater-mode [data-center] {
|
||||||
|
flex: 1 1 0; /* allow it to fill remaining space */
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-threecol].theater-mode [data-right] {
|
||||||
|
flex: 0 0 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: subtle emphasis on the center content while expanding */
|
||||||
|
[data-threecol] .threecol-row :is([data-center]) .carousel-theater-target,
|
||||||
|
[data-threecol] .threecol-row :is([data-center]) > * {
|
||||||
|
transition:
|
||||||
|
box-shadow 0.55s var(--transition-ease),
|
||||||
|
transform 0.55s var(--transition-ease);
|
||||||
|
}
|
||||||
|
[data-threecol].theater-mode [data-center] .carousel-theater-target,
|
||||||
|
[data-threecol].theater-mode [data-center] > * {
|
||||||
|
/* Example visual polish (comment out if not desired) */
|
||||||
|
/* box-shadow: 0 6px 24px -4px rgba(0,0,0,.25); */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-threecol] [data-left],
|
||||||
|
[data-threecol] [data-center],
|
||||||
|
[data-threecol] [data-right] {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue