mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
docs(docs): add Carousel component and embed in topological‑sort page, replace static spine image with interactive component
This commit is contained in:
1 parent
fd3b1952b1
commit
1a440e55ce
17 files changed
+635
-556
No files matched your search
@@ -0,0 +1,170 @@
|
||||
---
|
||||
/**
|
||||
* 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>
|
||||
Reference in new issue
Block a user