mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
171 lines
6.4 KiB
Plaintext
171 lines
6.4 KiB
Plaintext
---
|
|
/**
|
|
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
|
|
* content components are: static markup, a custom element for the behaviour,
|
|
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
|
|
* touch and the browser does the animation); prev/next sit outside it and
|
|
* the play toggle sits below with a "n / N" counter.
|
|
*
|
|
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
|
|
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
|
|
* never starts on its own for a reader who prefers reduced motion — the
|
|
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
|
|
* does not stop playback; it only restarts the interval so a step is never
|
|
* doubled.
|
|
*/
|
|
import Icon from "blume/components/Icon.astro";
|
|
|
|
interface Props {
|
|
/** Seconds each slide stays while playing. */
|
|
time?: number;
|
|
autoplay?: boolean;
|
|
}
|
|
|
|
const { time = 1.25, autoplay = false } = Astro.props;
|
|
|
|
const buttonClass =
|
|
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
|
|
---
|
|
|
|
<blume-carousel
|
|
class="not-prose my-6 block"
|
|
data-time={String(time)}
|
|
data-autoplay={autoplay ? "" : undefined}
|
|
role="region"
|
|
aria-roledescription="carousel"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
|
|
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
|
|
</button>
|
|
<div
|
|
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
data-track
|
|
tabindex="0"
|
|
aria-live="polite"
|
|
>
|
|
<slot />
|
|
</div>
|
|
<button class={buttonClass} data-next type="button" aria-label="Next slide">
|
|
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
|
|
</button>
|
|
</div>
|
|
<div class="mt-3 flex items-center justify-center gap-3">
|
|
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
|
|
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
|
|
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
|
|
</button>
|
|
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
|
|
</div>
|
|
</blume-carousel>
|
|
|
|
<script>
|
|
class BlumeCarousel extends HTMLElement {
|
|
#track!: HTMLElement;
|
|
#play!: HTMLButtonElement;
|
|
#count!: HTMLElement;
|
|
#timer: number | null = null;
|
|
#index = 0;
|
|
|
|
connectedCallback() {
|
|
this.#track = this.querySelector("[data-track]")!;
|
|
this.#play = this.querySelector("[data-play]")!;
|
|
this.#count = this.querySelector("[data-count]")!;
|
|
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
|
|
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
|
|
|
|
const slides = this.#slides().length;
|
|
if (slides < 2) {
|
|
for (const b of [prev, next, this.#play]) b.disabled = true;
|
|
}
|
|
this.#render();
|
|
|
|
prev.addEventListener("click", () => this.#step(-1));
|
|
next.addEventListener("click", () => this.#step(1));
|
|
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
|
|
this.addEventListener("keydown", (event) => {
|
|
if (event.key === "ArrowLeft") this.#step(-1);
|
|
else if (event.key === "ArrowRight") this.#step(1);
|
|
else return;
|
|
event.preventDefault();
|
|
});
|
|
// Swipes and trackpad scrolls move the track without #step; follow them
|
|
// once the scroll settles. Settle-only, not per event: a long smooth
|
|
// scroll from #step passes over intermediate slides and must not be
|
|
// read back as the reader's position mid-flight.
|
|
let settle: number | undefined;
|
|
this.#track.addEventListener(
|
|
"scroll",
|
|
() => {
|
|
window.clearTimeout(settle);
|
|
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
|
|
},
|
|
{ passive: true }
|
|
);
|
|
document.addEventListener("visibilitychange", this.#onVisibility);
|
|
|
|
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.#stop();
|
|
document.removeEventListener("visibilitychange", this.#onVisibility);
|
|
}
|
|
|
|
#slides(): HTMLElement[] {
|
|
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
|
|
}
|
|
|
|
#step(delta: number) {
|
|
const slides = this.#slides();
|
|
this.#index = (this.#index + delta + slides.length) % slides.length;
|
|
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
|
|
this.#render();
|
|
if (this.#timer !== null) this.#start(); // restart the interval from this slide
|
|
}
|
|
|
|
#syncFromScroll() {
|
|
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
|
|
if (at !== this.#index && Number.isFinite(at)) {
|
|
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
|
|
this.#render();
|
|
}
|
|
}
|
|
|
|
#start() {
|
|
this.#stop();
|
|
const ms = Number(this.dataset.time) * 1000;
|
|
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
|
|
this.#play.setAttribute("aria-pressed", "true");
|
|
this.#play.setAttribute("aria-label", "Pause");
|
|
}
|
|
|
|
#stop() {
|
|
if (this.#timer !== null) window.clearInterval(this.#timer);
|
|
this.#timer = null;
|
|
this.#play.setAttribute("aria-pressed", "false");
|
|
this.#play.setAttribute("aria-label", "Play");
|
|
}
|
|
|
|
// A background tab keeps its timers but not its layout; resume on return.
|
|
#wasPlaying = false;
|
|
#onVisibility = () => {
|
|
if (document.hidden) {
|
|
this.#wasPlaying = this.#timer !== null;
|
|
this.#stop();
|
|
} else if (this.#wasPlaying) this.#start();
|
|
};
|
|
|
|
#render() {
|
|
const slides = this.#slides();
|
|
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
|
|
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
|
|
}
|
|
}
|
|
|
|
if (!customElements.get("blume-carousel")) {
|
|
customElements.define("blume-carousel", BlumeCarousel);
|
|
}
|
|
</script>
|