@glasshome/ui 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/ui",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "SolidJS component library for GlassHome, built on Kobalte",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/glasshome/ui#readme",
@@ -78,6 +78,8 @@
78
78
  "clsx": "^2.1.1",
79
79
  "cva": "^1.0.0-beta.4",
80
80
  "embla-carousel": "^8.5.1",
81
+ "embla-carousel-autoplay": "^8.5.1",
82
+ "embla-carousel-fade": "^8.5.1",
81
83
  "solid-sonner": "^0.3.1",
82
84
  "tailwind-merge": "^3.4.0"
83
85
  },
@@ -0,0 +1,79 @@
1
+ ---
2
+ /**
3
+ * Server-rendered carousel. Slides live in the HTML, so they are indexable and
4
+ * the first one is the LCP image; the client script only adds behaviour.
5
+ * Use this on content pages; use the Solid <Carousel> inside an app.
6
+ *
7
+ * Slides go in the default slot as <CarouselItem>-shaped elements, or pass
8
+ * `count` to have dots rendered for you.
9
+ */
10
+ import {
11
+ CAROUSEL_DOTS,
12
+ CAROUSEL_VIEWPORT,
13
+ type CarouselTransition,
14
+ carouselDot,
15
+ carouselTrack,
16
+ } from "../lib/carousel-classes";
17
+ import { cn } from "../lib/utils";
18
+
19
+ interface Props {
20
+ transition?: CarouselTransition;
21
+ /** Advance every N ms. Skipped under prefers-reduced-motion. */
22
+ autoplay?: number;
23
+ loop?: boolean;
24
+ /** Slide count; when set, dots are rendered. */
25
+ count?: number;
26
+ class?: string;
27
+ }
28
+
29
+ const {
30
+ transition = "slide",
31
+ autoplay,
32
+ loop = false,
33
+ count,
34
+ class: className,
35
+ ...rest
36
+ } = Astro.props;
37
+ ---
38
+
39
+ <div
40
+ data-carousel
41
+ data-carousel-transition={transition}
42
+ data-carousel-autoplay={autoplay}
43
+ data-carousel-loop={loop ? "" : undefined}
44
+ class={cn("relative", className)}
45
+ role="region"
46
+ aria-roledescription="carousel"
47
+ {...rest}
48
+ >
49
+ <div data-carousel-viewport class={CAROUSEL_VIEWPORT}>
50
+ <div class={carouselTrack(transition)}>
51
+ <slot />
52
+ </div>
53
+ </div>
54
+
55
+ <slot name="controls" />
56
+
57
+ {
58
+ count && (
59
+ <div class={CAROUSEL_DOTS} data-carousel-dots>
60
+ {Array.from({ length: count }, (_, i) => (
61
+ <button
62
+ type="button"
63
+ data-carousel-dot
64
+ aria-label={`Go to slide ${i + 1}`}
65
+ aria-current={i === 0}
66
+ class={carouselDot(i === 0)}
67
+ />
68
+ ))}
69
+ </div>
70
+ )
71
+ }
72
+ </div>
73
+
74
+ <script>
75
+ import { initCarousels } from "../lib/carousel-init";
76
+
77
+ initCarousels();
78
+ document.addEventListener("astro:page-load", () => initCarousels());
79
+ </script>
@@ -0,0 +1,35 @@
1
+ /* Shared by the Solid <Carousel> and astro/Carousel.astro so the two render the
2
+ * same markup. Astro renders slides server-side (SEO, LCP) and enhances them on
3
+ * the client with the same embla engine. */
4
+
5
+ export type CarouselTransition = "slide" | "fade" | "wipe";
6
+
7
+ export const CAROUSEL_VIEWPORT = "h-full overflow-hidden";
8
+
9
+ /** Stacked modes put every slide in one grid cell; slide lays out a flex track. */
10
+ export function carouselTrack(
11
+ transition: CarouselTransition,
12
+ orientation: "horizontal" | "vertical" = "horizontal",
13
+ ) {
14
+ return transition === "slide"
15
+ ? `flex ${orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col"}`
16
+ : "carousel-stack";
17
+ }
18
+
19
+ export function carouselItem(
20
+ transition: CarouselTransition,
21
+ orientation: "horizontal" | "vertical" = "horizontal",
22
+ ) {
23
+ // Stacked slides fill their grid cell; only a flex track needs basis/gutter.
24
+ if (transition === "wipe") return "min-w-0 carousel-wipe";
25
+ if (transition === "fade") return "min-w-0";
26
+ return `min-w-0 shrink-0 grow-0 basis-full ${orientation === "horizontal" ? "pl-4" : "pt-4"}`;
27
+ }
28
+
29
+ export const CAROUSEL_DOTS = "flex items-center justify-center gap-2";
30
+
31
+ export function carouselDot(active: boolean) {
32
+ return `h-1.5 rounded-full transition-all hover:bg-foreground/70 ${
33
+ active ? "w-6 bg-foreground/80" : "w-1.5 bg-foreground/40"
34
+ }`;
35
+ }
@@ -0,0 +1,87 @@
1
+ import EmblaCarousel from "embla-carousel";
2
+ import Autoplay from "embla-carousel-autoplay";
3
+ import Fade from "embla-carousel-fade";
4
+ import { type CarouselTransition, carouselDot } from "./carousel-classes.js";
5
+
6
+ /**
7
+ * Client enhancement for astro/Carousel.astro. The markup is already in the
8
+ * server HTML; this only adds behaviour, so the slides stay visible (and
9
+ * indexable) if the script never runs.
10
+ *
11
+ * Emits `carousel:select` with `{ index }` so a host page can sync its own
12
+ * chrome (labels, lightboxes) without owning the carousel.
13
+ */
14
+ export function initCarousels(root: ParentNode = document) {
15
+ const roots = Array.from(root.querySelectorAll("[data-carousel]")) as HTMLElement[];
16
+ for (const el of roots) {
17
+ if (el.dataset.carouselReady) continue;
18
+ el.dataset.carouselReady = "1";
19
+
20
+ const viewport = el.querySelector("[data-carousel-viewport]") as HTMLElement | null;
21
+ if (!viewport) continue;
22
+
23
+ const transition = (el.dataset.carouselTransition ?? "slide") as CarouselTransition;
24
+ const autoplayMs = Number(el.dataset.carouselAutoplay ?? 0);
25
+ const loop = el.dataset.carouselLoop === "";
26
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
27
+
28
+ const plugins = [];
29
+ if (transition !== "slide") plugins.push(Fade());
30
+ if (autoplayMs > 0 && !reduced) {
31
+ // Manual nav should not kill autoplay for good; hovering pauses it and
32
+ // leaving resumes, which is what a hero carousel wants.
33
+ plugins.push(
34
+ Autoplay({
35
+ delay: autoplayMs,
36
+ stopOnInteraction: false,
37
+ stopOnMouseEnter: true,
38
+ stopOnFocusIn: true,
39
+ }),
40
+ );
41
+ }
42
+
43
+ const embla = EmblaCarousel(viewport, { loop }, plugins);
44
+ const dots = Array.from(el.querySelectorAll("[data-carousel-dot]")) as HTMLButtonElement[];
45
+
46
+ let previous = -1;
47
+ const onSelect = () => {
48
+ const index = embla.selectedScrollSnap();
49
+ embla.slideNodes().forEach((node, i) => {
50
+ node.toggleAttribute("data-selected", i === index);
51
+ // The outgoing slide stays visible one layer down until the sweep lands.
52
+ node.toggleAttribute("data-prev", i === previous && i !== index);
53
+ });
54
+ previous = index;
55
+ dots.forEach((dot, i) => {
56
+ dot.className = carouselDot(i === index);
57
+ dot.setAttribute("aria-current", String(i === index));
58
+ });
59
+ el.dispatchEvent(new CustomEvent("carousel:select", { detail: { index }, bubbles: true }));
60
+ };
61
+
62
+ el.addEventListener("keydown", (e) => {
63
+ const key = (e as KeyboardEvent).key;
64
+ if (key !== "ArrowLeft" && key !== "ArrowRight") return;
65
+ e.preventDefault();
66
+ if (key === "ArrowLeft") embla.scrollPrev();
67
+ else embla.scrollNext();
68
+ });
69
+
70
+ // A host that covers the carousel (a lightbox) pauses it, so the slide
71
+ // does not change behind the overlay.
72
+ const autoplay = (embla.plugins() as { autoplay?: { play: () => void; stop: () => void } })
73
+ .autoplay;
74
+ el.addEventListener("carousel:pause", () => autoplay?.stop());
75
+ el.addEventListener("carousel:resume", () => autoplay?.play());
76
+
77
+ dots.forEach((dot, i) => {
78
+ dot.addEventListener("click", () => embla.scrollTo(i));
79
+ });
80
+ el.querySelector("[data-carousel-prev]")?.addEventListener("click", () => embla.scrollPrev());
81
+ el.querySelector("[data-carousel-next]")?.addEventListener("click", () => embla.scrollNext());
82
+
83
+ onSelect();
84
+ embla.on("select", onSelect);
85
+ embla.on("reInit", onSelect);
86
+ }
87
+ }
@@ -101,6 +101,14 @@
101
101
  * mixed toward transparent-black down to mud on light surfaces. Deliberately
102
102
  * unlayered so the material owns border/background/box-shadow; tune via knobs. */
103
103
  :where(.glass) {
104
+ /* Descendant-readable mirror of this surface's tone. The --glass-* knobs are
105
+ * @property inherits:false so a nested glass element never picks up an
106
+ * ancestor's tint — correct for surfaces, but it also means a plain child
107
+ * (an icon, a label) reading var(--glass-tone) silently gets `transparent`
108
+ * and paints nothing. --surface-tone is an ordinary inheriting property, so
109
+ * children can read it; a nested .glass re-declares it from its own knob,
110
+ * which resets the chain and keeps the no-leak guarantee. */
111
+ --surface-tone: var(--glass-tone);
104
112
  border: 1px solid var(--glass-edge);
105
113
  /* Base is a gradient layer (not background-color) so the frost slice can sit
106
114
  * BELOW it — background-color always paints under every image layer, which
@@ -162,3 +170,54 @@
162
170
  background: var(--muted-foreground);
163
171
  }
164
172
  }
173
+
174
+ /* Stacked carousel modes (fade, wipe) put every slide in one grid cell. This is
175
+ * structural, so it ships as real CSS: a Tailwind arbitrary variant written in
176
+ * package source never reaches a consumer, because Tailwind skips node_modules
177
+ * when scanning for class names. */
178
+ .carousel-stack {
179
+ display: grid;
180
+ }
181
+ .carousel-stack > * {
182
+ grid-column: 1;
183
+ grid-row: 1;
184
+ }
185
+
186
+ /* Carousel `wipe`: the incoming slide sweeps in diagonally over the previous
187
+ * one, which stays fully visible beneath until the sweep lands. Only the
188
+ * incoming slide animates: giving the outgoing one a matching transition makes
189
+ * it collapse at the same time, which reads as two wipes from opposite corners.
190
+ * Slides are opaque here; embla's fade plugin only stops the track translating,
191
+ * and its opacity would wash out the sweep. */
192
+ .carousel-wipe {
193
+ /* Content choreography, not a state change: the motion scale's rungs are for
194
+ * micro-interactions and are far too quick to read as a sweep. Override per
195
+ * instance by setting --carousel-wipe on the carousel. */
196
+ --carousel-wipe: 1600ms;
197
+ clip-path: polygon(0 0, 300% 0, 0 300%, 0 0);
198
+ /* The fade plugin fades AND parks inactive slides off-screen
199
+ * (transform: translate(containerWidth + 2)), on embla's own short scroll
200
+ * duration. Both have to be neutralised or the sweep reveals empty space
201
+ * once the outgoing slide leaves: in wipe mode clip-path is the only reveal,
202
+ * and z-index alone decides what shows through. Inline styles, hence
203
+ * !important. */
204
+ opacity: 1 !important;
205
+ transform: none !important;
206
+ }
207
+ .carousel-wipe[data-prev] {
208
+ z-index: 1;
209
+ }
210
+ .carousel-wipe[data-selected] {
211
+ z-index: 2;
212
+ animation: carousel-wipe-in var(--carousel-wipe) ease-in-out;
213
+ }
214
+ @keyframes carousel-wipe-in {
215
+ from {
216
+ clip-path: polygon(0 0, 0 0, 0 0, 0 0);
217
+ }
218
+ }
219
+ @media (prefers-reduced-motion: reduce) {
220
+ .carousel-wipe[data-selected] {
221
+ animation: none;
222
+ }
223
+ }
@@ -160,6 +160,14 @@
160
160
  --shadow-2xl: 0px 2px 3px 0px hsl(0 0% 0% / 0.4);
161
161
  --tracking-normal: 0em;
162
162
  --spacing: 0.27rem;
163
+
164
+ /* Motion. Durations name the gesture, not the number: micro is a hover or
165
+ * border change, state a tone or chip swap, expand an open/close. Theme
166
+ * independent, so declared once; zeroed under prefers-reduced-motion. */
167
+ --ease-emphasis: cubic-bezier(0.22, 1, 0.36, 1);
168
+ --duration-micro: 120ms;
169
+ --duration-state: 160ms;
170
+ --duration-expand: 200ms;
163
171
  }
164
172
 
165
173
  .dark {
@@ -418,6 +426,13 @@
418
426
  ::view-transition-new(area-title) {
419
427
  animation: none !important;
420
428
  }
429
+
430
+ /* Anything timed off the motion scale stops moving, no per-component opt-in. */
431
+ :root {
432
+ --duration-micro: 0ms;
433
+ --duration-state: 0ms;
434
+ --duration-expand: 0ms;
435
+ }
421
436
  }
422
437
 
423
438
  /* Performance: disable all backdrop-filter when reduce-blur is active */