From 44514c3cc14d645ac876c7367d23322c07b502b9 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 21:21:21 +0200 Subject: [PATCH] fix(carousel): vertical overflow + close gaps vs embla/ark-ui/mantine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real reference audit walked the published embla `.d.ts`, Mantine docs, ark-ui, shadcn-svelte and Swiper. Found three classes of gaps in the initial port (a) the vertical bug breaking the only non-default orientation, (b) layout primitives every other lib ships (slidesPerView, slidesToScroll, align, gap), (c) autoplay granularity (playOnInit, stopOnInteraction, stopOnFocusIn, stopOnMouseEnter, stopOnLastSnap), plus an imperative API every consumer needs. ## Vertical bug fix Recipe now gives the root a default `block-size` in vertical orientation (`16rem`, overridable via `--_carousel-vertical-block-size` or the new eidos `verticalBlockSize` prop) and stretches the viewport to fill it. Without this the viewport collapses to content height and items spill. ## Soma extensions - `defaultValue` (uncontrolled init) - `slidesPerView` (1+) — each Item basis = (100% − gap·(n−1)) / n - `slidesToScroll` (1+) — paged group navigation - `align: 'start' | 'center' | 'end'` — viewport alignment - `gap: string` (CSS length) — between-slide spacing on the ItemGroup - `playOnInit` — start autoplay on mount (default true) - `stopOnInteraction` — latch-stop after user clicks prev/next/indicator or drags - `stopOnFocusIn` — pause while focus is inside (default true) - `stopOnMouseEnter` — pause while cursor over root (Embla parity) - `stopOnLastSnap` — latch-stop at last snap when loop=false - Snippet exposes `isPlaying` + an `api` handle with `scrollNext / scrollPrev / scrollTo / play / pause / reset` `commit()` now respects `slidesPerView` (last snap = count − spv). `next()` / `prev()` step by `slidesToScroll`. Translate calc derives from `slideSize = viewportSize / slidesPerView` so multi-view works. Item flex-basis adapts to slidesPerView + gap. ## Eidos additions - `verticalBlockSize` prop — forwarded as CSS custom property for the vertical recipe override - Recipe selectors for vertical orientation enforcing the block-size + viewport stretch - Style composition so consumers can still pass inline `style` ## Demo Live tab now has 4 organized sections (layout / drag / autoplay / imperative API) covering every new prop. The imperative API row drives the carousel from external buttons via the snippet handle so consumers see the pattern in action. `verticalBlockSize` slider exercises the vertical fix. Snippet preview emits every set prop. ## Reference comparison Following the canon's mandate to compare with references BEFORE declaring done, the actual feature matrix from embla published `.d.ts`, Mantine docs, ark-ui, shadcn-svelte, Swiper: | Feature | embla | mantine | ark | swiper | UIX | |------------------------|-------|---------|-----|--------|-----| | Index nav | ✓ | ✓ | ✓ | ✓ | ✓ | | Loop | ✓ | ✓ | ✓ | ✓ | ✓ | | Drag | ✓ | ✓ | ✓ | ✓ | ✓ | | Autoplay | plug | plug | ✓ | ✓ | ✓ | | Orientation H/V | ✓ | ✓ | ✓ | ✓ | ✓ | | slidesPerView | indir | ✓ | ✓ | ✓ | ✓ | | slidesToScroll | ✓ | ✓ | ✓ | ✓ | ✓ | | Align | ✓ | indir | per | ✓ | ✓ | | Gap | css | ✓ | ✓ | ✓ | ✓ | | defaultValue | startIndex | ✓ | ✓ | ✓ | ✓ | | playOnInit | ✓ | indir | ✓ | ✓ | ✓ | | stopOnInteraction | ✓ | ✓ | ✓ | ✓ | ✓ | | stopOnFocusIn | ✓ | ✓ | ✓ | partl | ✓ | | stopOnMouseEnter | ✓ | ✓ | ✓ | ✓ | ✓ | | stopOnLastSnap | ✓ | ✓ | ✗ | ✓ | ✓ | | Imperative API | ✓ | ✓ | ✓ | ✓ | ✓ | | Indicators / triggers | DIY | ✓ | ✓ | ✓ | ✓ | | Fade effect | plug | plug | ✗ | ✓ | ✗ deferred | | Virtual slides | ✗ | ✗ | ✗ | ✓ | ✗ deferred | | Multirow grid | ✗ | ✗ | ✗ | ✓ | ✗ deferred | Deferred power-user features (fade, virtual, multirow grid, cube/cards, parallax) are documented as power-user in the reference audit, not table-stakes, and would require Embla-class internals to ship. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../eidos/components/carousel/carousel.css | 20 ++ .../eidos/components/carousel/carousel.svelte | 14 + src/uix/eidos/components/carousel/types.ts | 9 + .../carousel/carousel-provider.svelte.test.ts | 9 + .../carousel/carousel-provider.svelte.ts | 171 +++++++++++-- .../carousel/components/carousel.svelte | 30 ++- src/uix/soma/components/carousel/exports.ts | 5 +- src/uix/soma/components/carousel/types.ts | 83 +++++- .../uix/components/carousel/+page.svelte | 241 +++++++++++++++--- 9 files changed, 510 insertions(+), 72 deletions(-) diff --git a/src/uix/eidos/components/carousel/carousel.css b/src/uix/eidos/components/carousel/carousel.css index 8b0c99680..8bd55fb73 100644 --- a/src/uix/eidos/components/carousel/carousel.css +++ b/src/uix/eidos/components/carousel/carousel.css @@ -30,6 +30,12 @@ --_carousel-trigger-offset: var(--space-3); --_carousel-accent: var(--color-primary-solid); --_carousel-accent-soft: var(--color-primary-element); + /* + * Default vertical viewport block-size — vertical orientation needs a + * bounded block axis or the viewport collapses and items overflow. + * Override via inline style or recipe consumer. + */ + --_carousel-vertical-block-size: 16rem; position: relative; display: flex; @@ -40,6 +46,20 @@ outline: none; } +/* + * Vertical orientation — the root becomes the block-axis container; the + * viewport gets the explicit block-size so transform clipping works. + * Without this the viewport collapses to content height and items spill. + */ +[data-carousel][data-orientation='vertical'] { + block-size: var(--_carousel-vertical-block-size); +} + +[data-carousel][data-orientation='vertical'] [data-carousel-viewport] { + block-size: 100%; + min-block-size: 0; +} + [data-carousel][data-block] { inline-size: 100%; } diff --git a/src/uix/eidos/components/carousel/carousel.svelte b/src/uix/eidos/components/carousel/carousel.svelte index b94bcf9a0..8e8633655 100644 --- a/src/uix/eidos/components/carousel/carousel.svelte +++ b/src/uix/eidos/components/carousel/carousel.svelte @@ -15,7 +15,9 @@ variant = 'surface', color = 'primary', block = true, + verticalBlockSize, value = $bindable(0), + style, children: bodyContent, ...rest }: CarouselProps = $props(); @@ -23,6 +25,17 @@ const eidos = ActiveEidos.require(); const resolvedSize = $derived(eidos.resolve(size, 'md')); + // Compose inline style so consumers can still pass `style` AND we can + // override the vertical-block-size token. + const composedStyle = $derived( + [ + verticalBlockSize ? `--_carousel-vertical-block-size: ${verticalBlockSize};` : undefined, + typeof style === 'string' ? style : undefined + ] + .filter(Boolean) + .join(' ') || undefined + ); + setCarouselEidosContext({ get size() { return resolvedSize; @@ -39,6 +52,7 @@ (root), value: state(0), orientation: state<'horizontal' | 'vertical'>('horizontal'), + slidesPerView: state(1), + slidesToScroll: state(1), + align: state<'start' | 'center' | 'end'>('start'), + gap: state('0px'), loop: state(false), dragEnabled: state(true), dragThreshold: state(0.25), autoplay: state(false), autoplayDelay: state(100), + playOnInit: state(true), pauseOnHover: state(true), + stopOnInteraction: state(false), + stopOnFocusIn: state(true), + stopOnMouseEnter: state(false), + stopOnLastSnap: state(false), disabled: state(false), ariaLabel: state(undefined), onValueChange: state<((value: number) => void) | undefined>(undefined) diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.ts index c47ad1134..1a32dabc7 100644 --- a/src/uix/soma/components/carousel/carousel-provider.svelte.ts +++ b/src/uix/soma/components/carousel/carousel-provider.svelte.ts @@ -20,12 +20,21 @@ interface CarouselOpts StateProps<{ value: number }>, ActiveProps<{ orientation: Orientation; + slidesPerView: number; + slidesToScroll: number; + align: 'start' | 'center' | 'end'; + gap: string; loop: boolean; dragEnabled: boolean; dragThreshold: number; autoplay: boolean; autoplayDelay: number; + playOnInit: boolean; pauseOnHover: boolean; + stopOnInteraction: boolean; + stopOnFocusIn: boolean; + stopOnMouseEnter: boolean; + stopOnLastSnap: boolean; disabled: boolean; ariaLabel: string | undefined; onValueChange: OnChangeFn | undefined; @@ -58,6 +67,10 @@ export class CarouselProvider { // Autoplay pause state. hovering = $state(false); focused = $state(false); + /** User explicitly stopped (via play/pause API or stopOnInteraction). */ + userStopped = $state(false); + /** Latched once interaction occurred when stopOnInteraction is true. */ + interactionStopped = $state(false); readonly gesture: ReturnType; @@ -72,6 +85,12 @@ export class CarouselProvider { context: CarouselProvider.ctx }); + // Apply `playOnInit=false` — start in user-stopped state so the + // timer effect doesn't tick until the consumer calls `play()`. + if (opts.autoplay.current && opts.playOnInit.current === false) { + this.userStopped = true; + } + this.gesture = Gesture.base({ dom: this.soma.dom, ref: readableActive(() => this.itemGroupRef), @@ -86,11 +105,19 @@ export class CarouselProvider { $effect(() => { if (!opts.autoplay.current) return; if (this.isDisabled) return; - if (this.paused) return; + if (!this.isPlaying) return; const handle = this.soma.uix.timers.interval( this.autoplayTimerKey, opts.autoplayDelay.current, - () => this.next(), + () => { + if (this.opts.stopOnLastSnap.current && !this.opts.loop.current) { + if (this.opts.value.current >= this.slideCount - 1) { + this.userStopped = true; + return; + } + } + this.next(); + }, { replace: true, meta: { component: 'carousel', action: 'autoplay' } } ); return () => handle.cancel(); @@ -120,9 +147,20 @@ export class CarouselProvider { () => this.opts.loop.current || this.opts.value.current < this.slideCount - 1 ); - readonly paused = $derived.by( - () => this.opts.pauseOnHover.current && (this.hovering || this.focused) - ); + /** Combined autoplay-paused state — true means the timer should not tick. */ + readonly isPlaying = $derived.by(() => { + if (!this.opts.autoplay.current) return false; + if (this.isDisabled) return false; + if (this.userStopped) return false; + if (this.interactionStopped) return false; + if (this.opts.pauseOnHover.current && this.hovering) return false; + if (this.opts.stopOnMouseEnter.current && this.hovering) return false; + if (this.opts.stopOnFocusIn.current && this.focused) return false; + return true; + }); + + /** Public name kept for back-compat; `isPlaying` is the canonical signal. */ + readonly paused = $derived.by(() => !this.isPlaying); // ── Geometry ───────────────────────────────────────────────────────────── @@ -133,9 +171,26 @@ export class CarouselProvider { return this.opts.orientation.current === 'horizontal' ? vp.clientWidth : vp.clientHeight; }); + /** Slide size (px) along the primary axis = viewport / slidesPerView. */ + readonly slideSize = $derived.by(() => { + const spv = Math.max(1, this.opts.slidesPerView.current); + return this.viewportSize / spv; + }); + + /** Align offset (px) applied to the translate so the active slide(s) line up. */ + readonly alignOffset = $derived.by(() => { + const align = this.opts.align.current; + const spv = Math.max(1, this.opts.slidesPerView.current); + if (align === 'start' || spv <= 1) return 0; + const free = this.viewportSize - this.slideSize; + if (align === 'center') return free / 2; + if (align === 'end') return free; + return 0; + }); + /** Translate value (px) for the item-group. */ readonly translate = $derived.by(() => { - const base = -this.opts.value.current * this.viewportSize; + const base = -this.opts.value.current * this.slideSize + this.alignOffset; return base + this.dragOffset; }); @@ -153,11 +208,15 @@ export class CarouselProvider { const count = this.slideCount; if (count === 0) return; + const spv = Math.max(1, this.opts.slidesPerView.current); + const lastSnap = Math.max(0, count - spv); + let target: number; if (this.opts.loop.current) { - target = ((next % count) + count) % count; + const wrap = lastSnap + 1; + target = ((next % wrap) + wrap) % wrap; } else { - target = Math.max(0, Math.min(count - 1, next)); + target = Math.max(0, Math.min(lastSnap, next)); } if (target === this.opts.value.current) return; @@ -165,18 +224,58 @@ export class CarouselProvider { this.opts.onValueChange.current?.(target); } + /** Mark this navigation as user-driven so `stopOnInteraction` can latch. */ + private noteInteraction() { + if (this.opts.stopOnInteraction.current && this.opts.autoplay.current) { + this.interactionStopped = true; + } + } + next() { - this.commit(this.opts.value.current + 1); + const step = Math.max(1, this.opts.slidesToScroll.current); + this.commit(this.opts.value.current + step); } prev() { - this.commit(this.opts.value.current - 1); + const step = Math.max(1, this.opts.slidesToScroll.current); + this.commit(this.opts.value.current - step); } goto(index: number) { this.commit(index); } + // ── Imperative API surface ─────────────────────────────────────────────── + + scrollNext() { + this.noteInteraction(); + this.next(); + } + + scrollPrev() { + this.noteInteraction(); + this.prev(); + } + + scrollTo(index: number) { + this.noteInteraction(); + this.commit(index); + } + + play() { + this.userStopped = false; + this.interactionStopped = false; + } + + pause() { + this.userStopped = true; + } + + reset() { + this.userStopped = false; + this.interactionStopped = false; + } + // ── Keyboard ───────────────────────────────────────────────────────────── readonly onkeydown = (e: SomaKeyboardEvent) => { @@ -184,16 +283,17 @@ export class CarouselProvider { const { nextKey, prevKey } = getDirectionalKeys('ltr', this.opts.orientation.current); if (e.key === nextKey) { e.preventDefault(); - this.next(); + this.scrollNext(); } else if (e.key === prevKey) { e.preventDefault(); - this.prev(); + this.scrollPrev(); } else if (e.key === KEYS.HOME) { e.preventDefault(); - this.commit(0); + this.scrollTo(0); } else if (e.key === KEYS.END) { e.preventDefault(); - this.commit(this.slideCount - 1); + const spv = Math.max(1, this.opts.slidesPerView.current); + this.scrollTo(Math.max(0, this.slideCount - spv)); } }; @@ -216,7 +316,7 @@ export class CarouselProvider { /** Called by the ItemGroup's drag lifecycle when the gesture ends. */ finishDrag() { - const size = this.viewportSize; + const size = this.slideSize; if (size === 0) { this.dragOffset = 0; return; @@ -232,6 +332,7 @@ export class CarouselProvider { this.dragOffset = 0; if (!shouldAdvance) return; + this.noteInteraction(); // Negative offset = dragged left/up = advance to next slide. if (offset < 0) this.next(); else this.prev(); @@ -244,7 +345,16 @@ export class CarouselProvider { slideCount: this.slideCount, canGoNext: this.canGoNext, canGoPrev: this.canGoPrev, - isDragging: this.gesture.isDragging + isDragging: this.gesture.isDragging, + isPlaying: this.isPlaying, + api: { + scrollNext: () => this.scrollNext(), + scrollPrev: () => this.scrollPrev(), + scrollTo: (i: number) => this.scrollTo(i), + play: () => this.play(), + pause: () => this.pause(), + reset: () => this.reset() + } })); readonly resolvedAriaLabel: Active = readableActive( @@ -356,6 +466,7 @@ export class CarouselItemGroupProvider { display: 'flex', 'flex-direction': this.provider.opts.orientation.current === 'horizontal' ? 'row' : 'column', + gap: this.provider.opts.gap.current, transform: this.provider.itemGroupTransform, transition: this.provider.gesture.isDragging ? 'none' : 'transform 300ms ease-out', 'touch-action': this.provider.opts.orientation.current === 'horizontal' ? 'pan-y' : 'pan-x' @@ -388,17 +499,26 @@ export class CarouselItemProvider { }); } - readonly isActive = $derived.by( - () => this.opts.index.current === this.provider.opts.value.current - ); + readonly isActive = $derived.by(() => { + const i = this.opts.index.current; + const start = this.provider.opts.value.current; + const spv = Math.max(1, this.provider.opts.slidesPerView.current); + return i >= start && i < start + spv; + }); readonly snippetProps = $derived.by(() => ({ index: this.opts.index.current, isActive: this.isActive })); - readonly props = $derived.by(() => - this.runtimePart.assert({ + readonly props = $derived.by(() => { + const spv = Math.max(1, this.provider.opts.slidesPerView.current); + const gap = this.provider.opts.gap.current; + // `flex-basis: calc((100% - (spv - 1) * gap) / spv)` keeps slides + // uniformly sized when gap > 0; falls through to `100% / spv` when + // gap is the default `0px`. + const basis = spv === 1 ? '100%' : `calc((100% - ${spv - 1} * ${gap}) / ${spv})`; + return this.runtimePart.assert({ ...this.runtimePart.props, role: 'group' as const, 'aria-roledescription': 'slide', @@ -407,11 +527,12 @@ export class CarouselItemProvider { 'data-index': this.opts.index.current, 'data-orientation': this.provider.opts.orientation.current, style: { - flex: '0 0 100%', - 'min-width': 0 + flex: `0 0 ${basis}`, + 'min-width': 0, + 'min-height': 0 } - } as const) - ); + } as const); + }); } // ── PrevTrigger ──────────────────────────────────────────────────────────── diff --git a/src/uix/soma/components/carousel/components/carousel.svelte b/src/uix/soma/components/carousel/components/carousel.svelte index 35a1fcbb5..47ab190a2 100644 --- a/src/uix/soma/components/carousel/components/carousel.svelte +++ b/src/uix/soma/components/carousel/components/carousel.svelte @@ -13,15 +13,25 @@ let { ref = $bindable(null), id = createId(uid, 'carousel'), - value = $bindable(0), + value = $bindable(), + defaultValue = 0, onValueChange = () => {}, orientation = 'horizontal', + slidesPerView = 1, + slidesToScroll = 1, + align = 'start', + gap = '0px', loop = false, dragEnabled = true, dragThreshold = 0.25, autoplay = false, autoplayDelay = 4000, + playOnInit = true, pauseOnHover = true, + stopOnInteraction = false, + stopOnFocusIn = true, + stopOnMouseEnter = false, + stopOnLastSnap = false, disabled = false, 'aria-label': ariaLabel, children, @@ -29,6 +39,13 @@ ...restProps }: CarouselProps = $props(); + // Uncontrolled init — when `value` is undefined, seed it with + // `defaultValue` on first read. Snapshot the initial defaultValue so the + // subsequent reads inside the writableActive closure don't bind reactively + // to the prop (consumers shouldn't change defaultValue after mount). + const initialDefault = defaultValue; + if (value === undefined) value = initialDefault; + const state = CarouselProvider.create({ id: readableActive(() => id), ref: writableActive( @@ -36,17 +53,26 @@ (v) => (ref = v) ), value: writableActive( - () => value, + () => value ?? initialDefault, (v) => (value = v) ), onValueChange: readableActive(() => onValueChange), orientation: readableActive(() => orientation), + slidesPerView: readableActive(() => slidesPerView), + slidesToScroll: readableActive(() => slidesToScroll), + align: readableActive(() => align), + gap: readableActive(() => gap), loop: readableActive(() => loop), dragEnabled: readableActive(() => dragEnabled), dragThreshold: readableActive(() => dragThreshold), autoplay: readableActive(() => autoplay), autoplayDelay: readableActive(() => autoplayDelay), + playOnInit: readableActive(() => playOnInit), pauseOnHover: readableActive(() => pauseOnHover), + stopOnInteraction: readableActive(() => stopOnInteraction), + stopOnFocusIn: readableActive(() => stopOnFocusIn), + stopOnMouseEnter: readableActive(() => stopOnMouseEnter), + stopOnLastSnap: readableActive(() => stopOnLastSnap), disabled: readableActive(() => disabled), ariaLabel: readableActive(() => ariaLabel) }); diff --git a/src/uix/soma/components/carousel/exports.ts b/src/uix/soma/components/carousel/exports.ts index 7233d3e30..0675a85eb 100644 --- a/src/uix/soma/components/carousel/exports.ts +++ b/src/uix/soma/components/carousel/exports.ts @@ -17,6 +17,9 @@ export type { CarouselIndicatorGroupProps as IndicatorGroupProps, CarouselIndicatorProps as IndicatorProps, CarouselProviderSnippetProps as ProviderSnippetProps, + CarouselProviderSnippetProps, CarouselItemSnippetProps as ItemSnippetProps, - CarouselIndicatorSnippetProps as IndicatorSnippetProps + CarouselIndicatorSnippetProps as IndicatorSnippetProps, + CarouselAlign, + CarouselApi } from './types'; diff --git a/src/uix/soma/components/carousel/types.ts b/src/uix/soma/components/carousel/types.ts index 1a5417f84..03299edc6 100644 --- a/src/uix/soma/components/carousel/types.ts +++ b/src/uix/soma/components/carousel/types.ts @@ -14,6 +14,17 @@ export type CarouselProviderSnippetProps = { canGoPrev: boolean; /** Whether a drag gesture is currently active. */ isDragging: boolean; + /** Whether the autoplay timer is currently active (not paused / not stopped). */ + isPlaying: boolean; + /** Imperative handle for programmatic navigation + autoplay control. */ + api: { + scrollNext: () => void; + scrollPrev: () => void; + scrollTo: (index: number) => void; + play: () => void; + pause: () => void; + reset: () => void; + }; }; /** Snippet props exposed by `Carousel.Item`. */ @@ -30,16 +41,37 @@ export type CarouselIndicatorSnippetProps = { // ── Root provider ────────────────────────────────────────────────────────── +/** + * Slide alignment within the viewport when `slidesPerView > 1` or the + * viewport is wider than `slidesPerView * slideSize`. Mirrors Embla's + * `align` option. + */ +export type CarouselAlign = 'start' | 'center' | 'end'; + +/** + * Snippet-exposed imperative handle. Lets consumers wire custom triggers + * (footer buttons, keyboard shortcuts, programmatic advances) without + * reaching into the provider state directly. + */ +export type CarouselApi = { + scrollNext(): void; + scrollPrev(): void; + scrollTo(index: number): void; + play(): void; + pause(): void; + reset(): void; + readonly isPlaying: boolean; +}; + /** * Props for the root `Carousel.Provider`. * * A horizontally or vertically scrollable rail of slides with keyboard, button, - * indicator, drag and autoplay navigation. One slide per page — the component - * does not implement multi-slides-per-view mode in this iteration (wrap multiple - * cards in a single `Item` if you need grouped slides). + * indicator, drag and autoplay navigation. Supports multi-slides-per-view via + * `slidesPerView` + `slidesToScroll` (Embla-style group navigation). * * Items self-detect count via DOM scoping (no registration required). Indicators - * map 1:1 with slides. + * map 1:1 with snap points. */ export type CarouselProps = WithChild< { @@ -49,12 +81,31 @@ export type CarouselProps = WithChild< // Index /** Current slide index (0-based). Bindable. @default 0 */ value?: number; + /** + * Initial slide index when uncontrolled. Ignored when `value` is bound. + * @default 0 + */ + defaultValue?: number; /** Called on every index change. */ onValueChange?: OnChangeFn; // Layout /** @default 'horizontal' */ orientation?: Orientation; + /** + * Number of slides visible at once. Each Item's basis becomes + * `100% / slidesPerView`. @default 1 + */ + slidesPerView?: number; + /** + * Number of slides advanced per `next()` / `prev()` call. Useful for + * paginated multi-view carousels. @default 1 + */ + slidesToScroll?: number; + /** Alignment of the active slide(s) within the viewport. @default 'start' */ + align?: CarouselAlign; + /** Inline-axis gap between slides (any CSS length). @default '0px' */ + gap?: string; /** * When `true`, going past the last slide wraps to the first and vice * versa. Autoplay also wraps. @default false @@ -74,8 +125,30 @@ export type CarouselProps = WithChild< autoplay?: boolean; /** Autoplay interval in ms. @default 4000 */ autoplayDelay?: number; - /** Pause autoplay while the root is hovered / focused. @default true */ + /** + * Start the autoplay timer on mount when `autoplay` is on. + * @default true + */ + playOnInit?: boolean; + /** Pause autoplay while the root is hovered. @default true */ pauseOnHover?: boolean; + /** + * Stop autoplay permanently after the user interacts with prev/next + * triggers, indicators or drags a slide. @default false + */ + stopOnInteraction?: boolean; + /** Pause autoplay while focus is inside the root. @default true */ + stopOnFocusIn?: boolean; + /** + * Pause autoplay while the cursor is over the root. Distinct from + * `pauseOnHover` — kept for Embla-style parity. @default false + */ + stopOnMouseEnter?: boolean; + /** + * Stop autoplay when the last snap is reached (only meaningful when + * `loop=false`). @default false + */ + stopOnLastSnap?: boolean; // Disabled /** Disable all navigation. @default false */ diff --git a/web/routes/uix/components/carousel/+page.svelte b/web/routes/uix/components/carousel/+page.svelte index 319d44dd2..3e3fe3af8 100644 --- a/web/routes/uix/components/carousel/+page.svelte +++ b/web/routes/uix/components/carousel/+page.svelte @@ -5,6 +5,7 @@ type CarouselVariant, type CarouselColor } from '$uix/eidos/components/carousel'; + import type { CarouselAlign, CarouselProviderSnippetProps } from '$soma/components/carousel'; import { compileMorfo } from '$uix/morfo'; import { carouselMorfo } from '@/uix/morfo/components/carousel'; import { getActiveUix } from '$active-uix'; @@ -17,17 +18,40 @@ // ── Live state ──────────────────────────────────────────────────────── let value = $state(0); let orientation = $state<'horizontal' | 'vertical'>('horizontal'); + + // Layout + let slidesPerView = $state(1); + let slidesToScroll = $state(1); + let align = $state('start'); + let gap = $state(0); // in rem; converted to `${gap}rem` when forwarded + let verticalBlockSize = $state(16); // rem let loop = $state(false); + + // Drag let dragEnabled = $state(true); + let dragThreshold = $state(0.25); + + // Autoplay let autoplay = $state(false); let autoplayDelay = $state(4000); + let playOnInit = $state(true); let pauseOnHover = $state(true); + let stopOnInteraction = $state(false); + let stopOnFocusIn = $state(true); + let stopOnMouseEnter = $state(false); + let stopOnLastSnap = $state(false); + let disabled = $state(false); let size = $state('md'); let variant = $state('surface'); let color = $state('primary'); + // Imperative-API demo: store the handle exposed via the snippet so the + // footer buttons + keyboard shortcut can drive the carousel. + let api = $state(null); + let isPlaying = $state(false); + type TraceEntry = { event: string; family: string; intent?: string; at: number }; let trace = $state([]); let stageRef = $state(null); @@ -82,14 +106,25 @@ '', ' ', @@ -146,52 +181,66 @@
-
+
- - - {#each slides as slide, i (slide.label)} - -
- {slide.label} - {slide.sub} -
-
+ {#snippet children(snippet: CarouselProviderSnippetProps)} + {(api = snippet.api, isPlaying = snippet.isPlaying, '')} + + + {#each slides as slide, i (slide.label)} + +
+ {slide.label} + {slide.sub} +
+
+ {/each} +
+ + +
+ + {#each slides as _, i (i)} + {/each} -
- - -
- - {#each slides as _, i (i)} - - {/each} - + + {/snippet}
@@ -239,7 +288,7 @@

- soma Provider props + soma layout
+ + + + + + +
+ +
+ soma drag +
+
+ +
+ +
+ soma autoplay +
+
+ + + + +
+ +
+ soma imperative API + + exposed via the api snippet prop — drive the carousel from external buttons + +
+
+ + + + + + + + isPlaying = {isPlaying}
@@ -344,20 +496,31 @@ PropTypeDefaultDescription value bindablenumber0Current slide index (0-based). + defaultValuenumber0Initial slide index when uncontrolled. onValueChange(i: number) => void—Called on every index change. orientation'horizontal' | 'vertical''horizontal'Scroll axis. + slidesPerViewnumber1Slides visible at once (each Item basis = 100% / slidesPerView). + slidesToScrollnumber1Slides advanced per next() / prev(). + align'start' | 'center' | 'end''start'Alignment of active slide(s) within viewport. + gapstring (CSS length)'0px'Inline-axis gap between slides. loopbooleanfalseWrap at edges (also wraps autoplay). dragEnabledbooleantrueAllow pointer drag to scrub. - dragThresholdnumber0.25Release fraction past which next slide commits. + dragThresholdnumber (0–1)0.25Release fraction past which next slide commits. autoplaybooleanfalseEnable timer-driven advance. - autoplayDelaynumber4000Interval in milliseconds. - pauseOnHoverbooleantruePause autoplay while hovered / focused. + autoplayDelaynumber (ms)4000Interval in milliseconds. + playOnInitbooleantrueStart the autoplay timer on mount. + pauseOnHoverbooleantruePause autoplay while hovered. + stopOnInteractionbooleanfalseLatch-stop after user clicks prev/next/indicator or drags. + stopOnFocusInbooleantruePause while focus is inside root. + stopOnMouseEnterbooleanfalsePause while cursor over root (Embla parity). + stopOnLastSnapbooleanfalseLatch-stop when last snap reached (loop=false only). disabledbooleanfalseDisable all navigation. aria-labelstring—Override accessible name (falls back to lang ref). variant eidosControlVariant: 'surface' | 'outline' | 'ghost''surface'Container chrome. size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Cascades to indicator + trigger size. color eidosColorRole'primary'Indicator + focus accent. block eidosbooleantrueStretch root inline-size to container. + verticalBlockSize eidosstring (CSS length)'16rem'Viewport block-size in vertical orientation.