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 carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const helpId = `${carouselId}-help`;
|
||||
---
|
||||
|
||||
<section
|
||||
|
|
@ -21,21 +22,22 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
|||
aria-roledescription="carousel"
|
||||
aria-label="Image carousel"
|
||||
data-carousel-id={carouselId}
|
||||
tabindex="0"
|
||||
aria-describedby={helpId}
|
||||
>
|
||||
<!-- Slides -->
|
||||
<div class="relative h-0 pb-[56.25%]">
|
||||
<!-- 16:9 ratio placeholder -->
|
||||
{
|
||||
images.map((image, i) => (
|
||||
<img
|
||||
src={image.src.src}
|
||||
alt={image.alt}
|
||||
class={`
|
||||
absolute inset-0 w-full h-full object-cover object-top
|
||||
transition-opacity duration-500 ease-in-out
|
||||
${i === 0 ? "opacity-100" : "opacity-0"}
|
||||
carousel-slide
|
||||
`}
|
||||
absolute inset-0 w-full h-full object-cover object-top
|
||||
transition-opacity duration-500 ease-in-out
|
||||
${i === 0 ? "opacity-100" : "opacity-0"}
|
||||
carousel-slide
|
||||
`}
|
||||
data-index={i}
|
||||
/>
|
||||
))
|
||||
|
|
@ -73,6 +75,20 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
|||
<span class="sr-only">Next</span>
|
||||
</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 -->
|
||||
<div
|
||||
class="sr-only"
|
||||
|
|
@ -81,12 +97,15 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
|||
data-live-announcer
|
||||
>
|
||||
</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>
|
||||
|
||||
<script define:vars={{ carouselId }}>
|
||||
// -----------------------------------------------------------------
|
||||
// Carousel logic – runs once the component is in the DOM.
|
||||
// -----------------------------------------------------------------
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const carousel = document.getElementById(carouselId);
|
||||
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 nextBtn = carousel.querySelector('[data-dir="next"]');
|
||||
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
|
||||
const theaterBtn = carousel.querySelector("[data-theater-toggle]");
|
||||
|
||||
let current = 0;
|
||||
const total = slides.length;
|
||||
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) => {
|
||||
slides[current].classList.remove("opacity-100");
|
||||
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.add("opacity-100");
|
||||
|
||||
// Update screen‑reader announcement
|
||||
if (liveAnnouncer) {
|
||||
liveAnnouncerContent = `Slide ${current + 1} of ${total}`;
|
||||
liveAnnouncer.textContent = `Slide ${current + 1} of ${total}`;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Navigation button handlers
|
||||
// -----------------------------------------------------------------
|
||||
const goPrev = () => {
|
||||
const prev = (current - 1 + total) % total;
|
||||
showSlide(prev);
|
||||
|
|
@ -133,24 +183,37 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
|||
prevBtn?.addEventListener("click", goPrev);
|
||||
nextBtn?.addEventListener("click", goNext);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Keyboard navigation (left / right arrows)
|
||||
// -----------------------------------------------------------------
|
||||
// Key handling (now works when the section itself is focused)
|
||||
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") {
|
||||
e.preventDefault();
|
||||
goPrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
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 = () => {
|
||||
stopAutoRotate(); // safety
|
||||
stopAutoRotate();
|
||||
autoRotateTimer = window.setInterval(goNext, 5000);
|
||||
};
|
||||
const stopAutoRotate = () => {
|
||||
|
|
@ -160,10 +223,8 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
|||
}
|
||||
};
|
||||
|
||||
// Start rotating when the component mounts
|
||||
startAutoRotate();
|
||||
|
||||
// Pause when the user hovers or focuses inside the carousel
|
||||
carousel.addEventListener("mouseenter", stopAutoRotate);
|
||||
carousel.addEventListener("mouseleave", startAutoRotate);
|
||||
carousel.addEventListener("focusin", stopAutoRotate);
|
||||
|
|
|
|||
|
|
@ -27,23 +27,113 @@ const maxWidthClasses = {
|
|||
};
|
||||
---
|
||||
|
||||
<section class={`${bgClass} ${py}`}>
|
||||
<section class={`${bgClass} ${py}`} data-threecol>
|
||||
<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 (Hidden on mobile, visible on desktop) -->
|
||||
<div class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"}`}>
|
||||
<!-- Left Column -->
|
||||
<div
|
||||
class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"} flex-col`}
|
||||
data-left
|
||||
>
|
||||
<slot name="left" />
|
||||
</div>
|
||||
|
||||
<!-- Center Column (Main content) -->
|
||||
<div class={`order-1 lg:order-2`}>
|
||||
<!-- Center Column -->
|
||||
<div class={`order-1 lg:order-2 flex-col`} data-center>
|
||||
<slot name="center" />
|
||||
</div>
|
||||
|
||||
<!-- 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" />
|
||||
</div>
|
||||
</div>
|
||||
</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