fix(carousel): vertical overflow + close gaps vs embla/ark-ui/mantine

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 2cecc0b3b7
commit 44514c3cc1

@ -30,6 +30,12 @@
--_carousel-trigger-offset: var(--space-3); --_carousel-trigger-offset: var(--space-3);
--_carousel-accent: var(--color-primary-solid); --_carousel-accent: var(--color-primary-solid);
--_carousel-accent-soft: var(--color-primary-element); --_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; position: relative;
display: flex; display: flex;
@ -40,6 +46,20 @@
outline: none; 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] { [data-carousel][data-block] {
inline-size: 100%; inline-size: 100%;
} }

@ -15,7 +15,9 @@
variant = 'surface', variant = 'surface',
color = 'primary', color = 'primary',
block = true, block = true,
verticalBlockSize,
value = $bindable(0), value = $bindable(0),
style,
children: bodyContent, children: bodyContent,
...rest ...rest
}: CarouselProps = $props(); }: CarouselProps = $props();
@ -23,6 +25,17 @@
const eidos = ActiveEidos.require(); const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md')); 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({ setCarouselEidosContext({
get size() { get size() {
return resolvedSize; return resolvedSize;
@ -39,6 +52,7 @@
<Carousel.Provider <Carousel.Provider
{...rest} {...rest}
bind:value bind:value
style={composedStyle}
data-size={resolvedSize} data-size={resolvedSize}
data-variant={variant} data-variant={variant}
data-color={color} data-color={color}

@ -58,6 +58,15 @@ export type CarouselProps = ProviderProps & {
color?: CarouselColor; color?: CarouselColor;
/** Stretch the root to fill its container's inline-size. @default true */ /** Stretch the root to fill its container's inline-size. @default true */
block?: boolean; block?: boolean;
/**
* Block-size for the viewport in vertical orientation. Pure horizontal
* carousels don't need this — the recipe is content-sized along the block
* axis. In vertical, the viewport must be bounded or items overflow;
* default is `16rem`. Accepts any CSS length.
*
* @default '16rem'
*/
verticalBlockSize?: string;
}; };
export type CarouselViewportProps = ViewportProps; export type CarouselViewportProps = ViewportProps;

@ -77,12 +77,21 @@ function carouselOpts(root = document.createElement('section')) {
ref: state<HTMLElement | null>(root), ref: state<HTMLElement | null>(root),
value: state(0), value: state(0),
orientation: state<'horizontal' | 'vertical'>('horizontal'), orientation: state<'horizontal' | 'vertical'>('horizontal'),
slidesPerView: state(1),
slidesToScroll: state(1),
align: state<'start' | 'center' | 'end'>('start'),
gap: state('0px'),
loop: state(false), loop: state(false),
dragEnabled: state(true), dragEnabled: state(true),
dragThreshold: state(0.25), dragThreshold: state(0.25),
autoplay: state(false), autoplay: state(false),
autoplayDelay: state(100), autoplayDelay: state(100),
playOnInit: state(true),
pauseOnHover: state(true), pauseOnHover: state(true),
stopOnInteraction: state(false),
stopOnFocusIn: state(true),
stopOnMouseEnter: state(false),
stopOnLastSnap: state(false),
disabled: state(false), disabled: state(false),
ariaLabel: state<string | undefined>(undefined), ariaLabel: state<string | undefined>(undefined),
onValueChange: state<((value: number) => void) | undefined>(undefined) onValueChange: state<((value: number) => void) | undefined>(undefined)

@ -20,12 +20,21 @@ interface CarouselOpts
StateProps<{ value: number }>, StateProps<{ value: number }>,
ActiveProps<{ ActiveProps<{
orientation: Orientation; orientation: Orientation;
slidesPerView: number;
slidesToScroll: number;
align: 'start' | 'center' | 'end';
gap: string;
loop: boolean; loop: boolean;
dragEnabled: boolean; dragEnabled: boolean;
dragThreshold: number; dragThreshold: number;
autoplay: boolean; autoplay: boolean;
autoplayDelay: number; autoplayDelay: number;
playOnInit: boolean;
pauseOnHover: boolean; pauseOnHover: boolean;
stopOnInteraction: boolean;
stopOnFocusIn: boolean;
stopOnMouseEnter: boolean;
stopOnLastSnap: boolean;
disabled: boolean; disabled: boolean;
ariaLabel: string | undefined; ariaLabel: string | undefined;
onValueChange: OnChangeFn<number> | undefined; onValueChange: OnChangeFn<number> | undefined;
@ -58,6 +67,10 @@ export class CarouselProvider {
// Autoplay pause state. // Autoplay pause state.
hovering = $state(false); hovering = $state(false);
focused = $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<typeof Gesture.base>; readonly gesture: ReturnType<typeof Gesture.base>;
@ -72,6 +85,12 @@ export class CarouselProvider {
context: CarouselProvider.ctx 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({ this.gesture = Gesture.base({
dom: this.soma.dom, dom: this.soma.dom,
ref: readableActive(() => this.itemGroupRef), ref: readableActive(() => this.itemGroupRef),
@ -86,11 +105,19 @@ export class CarouselProvider {
$effect(() => { $effect(() => {
if (!opts.autoplay.current) return; if (!opts.autoplay.current) return;
if (this.isDisabled) return; if (this.isDisabled) return;
if (this.paused) return; if (!this.isPlaying) return;
const handle = this.soma.uix.timers.interval( const handle = this.soma.uix.timers.interval(
this.autoplayTimerKey, this.autoplayTimerKey,
opts.autoplayDelay.current, 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' } } { replace: true, meta: { component: 'carousel', action: 'autoplay' } }
); );
return () => handle.cancel(); return () => handle.cancel();
@ -120,9 +147,20 @@ export class CarouselProvider {
() => this.opts.loop.current || this.opts.value.current < this.slideCount - 1 () => this.opts.loop.current || this.opts.value.current < this.slideCount - 1
); );
readonly paused = $derived.by( /** Combined autoplay-paused state — true means the timer should not tick. */
() => this.opts.pauseOnHover.current && (this.hovering || this.focused) 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 ───────────────────────────────────────────────────────────── // ── Geometry ─────────────────────────────────────────────────────────────
@ -133,9 +171,26 @@ export class CarouselProvider {
return this.opts.orientation.current === 'horizontal' ? vp.clientWidth : vp.clientHeight; 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. */ /** Translate value (px) for the item-group. */
readonly translate = $derived.by(() => { 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; return base + this.dragOffset;
}); });
@ -153,11 +208,15 @@ export class CarouselProvider {
const count = this.slideCount; const count = this.slideCount;
if (count === 0) return; if (count === 0) return;
const spv = Math.max(1, this.opts.slidesPerView.current);
const lastSnap = Math.max(0, count - spv);
let target: number; let target: number;
if (this.opts.loop.current) { if (this.opts.loop.current) {
target = ((next % count) + count) % count; const wrap = lastSnap + 1;
target = ((next % wrap) + wrap) % wrap;
} else { } 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; if (target === this.opts.value.current) return;
@ -165,18 +224,58 @@ export class CarouselProvider {
this.opts.onValueChange.current?.(target); 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() { 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() { 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) { goto(index: number) {
this.commit(index); 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 ───────────────────────────────────────────────────────────── // ── Keyboard ─────────────────────────────────────────────────────────────
readonly onkeydown = (e: SomaKeyboardEvent<HTMLDivElement>) => { readonly onkeydown = (e: SomaKeyboardEvent<HTMLDivElement>) => {
@ -184,16 +283,17 @@ export class CarouselProvider {
const { nextKey, prevKey } = getDirectionalKeys('ltr', this.opts.orientation.current); const { nextKey, prevKey } = getDirectionalKeys('ltr', this.opts.orientation.current);
if (e.key === nextKey) { if (e.key === nextKey) {
e.preventDefault(); e.preventDefault();
this.next(); this.scrollNext();
} else if (e.key === prevKey) { } else if (e.key === prevKey) {
e.preventDefault(); e.preventDefault();
this.prev(); this.scrollPrev();
} else if (e.key === KEYS.HOME) { } else if (e.key === KEYS.HOME) {
e.preventDefault(); e.preventDefault();
this.commit(0); this.scrollTo(0);
} else if (e.key === KEYS.END) { } else if (e.key === KEYS.END) {
e.preventDefault(); 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. */ /** Called by the ItemGroup's drag lifecycle when the gesture ends. */
finishDrag() { finishDrag() {
const size = this.viewportSize; const size = this.slideSize;
if (size === 0) { if (size === 0) {
this.dragOffset = 0; this.dragOffset = 0;
return; return;
@ -232,6 +332,7 @@ export class CarouselProvider {
this.dragOffset = 0; this.dragOffset = 0;
if (!shouldAdvance) return; if (!shouldAdvance) return;
this.noteInteraction();
// Negative offset = dragged left/up = advance to next slide. // Negative offset = dragged left/up = advance to next slide.
if (offset < 0) this.next(); if (offset < 0) this.next();
else this.prev(); else this.prev();
@ -244,7 +345,16 @@ export class CarouselProvider {
slideCount: this.slideCount, slideCount: this.slideCount,
canGoNext: this.canGoNext, canGoNext: this.canGoNext,
canGoPrev: this.canGoPrev, 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<string | undefined> = readableActive( readonly resolvedAriaLabel: Active<string | undefined> = readableActive(
@ -356,6 +466,7 @@ export class CarouselItemGroupProvider {
display: 'flex', display: 'flex',
'flex-direction': 'flex-direction':
this.provider.opts.orientation.current === 'horizontal' ? 'row' : 'column', this.provider.opts.orientation.current === 'horizontal' ? 'row' : 'column',
gap: this.provider.opts.gap.current,
transform: this.provider.itemGroupTransform, transform: this.provider.itemGroupTransform,
transition: this.provider.gesture.isDragging ? 'none' : 'transform 300ms ease-out', transition: this.provider.gesture.isDragging ? 'none' : 'transform 300ms ease-out',
'touch-action': this.provider.opts.orientation.current === 'horizontal' ? 'pan-y' : 'pan-x' 'touch-action': this.provider.opts.orientation.current === 'horizontal' ? 'pan-y' : 'pan-x'
@ -388,17 +499,26 @@ export class CarouselItemProvider {
}); });
} }
readonly isActive = $derived.by( readonly isActive = $derived.by(() => {
() => this.opts.index.current === this.provider.opts.value.current 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(() => ({ readonly snippetProps = $derived.by(() => ({
index: this.opts.index.current, index: this.opts.index.current,
isActive: this.isActive isActive: this.isActive
})); }));
readonly props = $derived.by(() => readonly props = $derived.by(() => {
this.runtimePart.assert({ 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, ...this.runtimePart.props,
role: 'group' as const, role: 'group' as const,
'aria-roledescription': 'slide', 'aria-roledescription': 'slide',
@ -407,11 +527,12 @@ export class CarouselItemProvider {
'data-index': this.opts.index.current, 'data-index': this.opts.index.current,
'data-orientation': this.provider.opts.orientation.current, 'data-orientation': this.provider.opts.orientation.current,
style: { style: {
flex: '0 0 100%', flex: `0 0 ${basis}`,
'min-width': 0 'min-width': 0,
'min-height': 0
} }
} as const) } as const);
); });
} }
// ── PrevTrigger ──────────────────────────────────────────────────────────── // ── PrevTrigger ────────────────────────────────────────────────────────────

@ -13,15 +13,25 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
id = createId(uid, 'carousel'), id = createId(uid, 'carousel'),
value = $bindable(0), value = $bindable(),
defaultValue = 0,
onValueChange = () => {}, onValueChange = () => {},
orientation = 'horizontal', orientation = 'horizontal',
slidesPerView = 1,
slidesToScroll = 1,
align = 'start',
gap = '0px',
loop = false, loop = false,
dragEnabled = true, dragEnabled = true,
dragThreshold = 0.25, dragThreshold = 0.25,
autoplay = false, autoplay = false,
autoplayDelay = 4000, autoplayDelay = 4000,
playOnInit = true,
pauseOnHover = true, pauseOnHover = true,
stopOnInteraction = false,
stopOnFocusIn = true,
stopOnMouseEnter = false,
stopOnLastSnap = false,
disabled = false, disabled = false,
'aria-label': ariaLabel, 'aria-label': ariaLabel,
children, children,
@ -29,6 +39,13 @@
...restProps ...restProps
}: CarouselProps = $props(); }: 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({ const state = CarouselProvider.create({
id: readableActive(() => id), id: readableActive(() => id),
ref: writableActive( ref: writableActive(
@ -36,17 +53,26 @@
(v) => (ref = v) (v) => (ref = v)
), ),
value: writableActive( value: writableActive(
() => value, () => value ?? initialDefault,
(v) => (value = v) (v) => (value = v)
), ),
onValueChange: readableActive(() => onValueChange), onValueChange: readableActive(() => onValueChange),
orientation: readableActive(() => orientation), orientation: readableActive(() => orientation),
slidesPerView: readableActive(() => slidesPerView),
slidesToScroll: readableActive(() => slidesToScroll),
align: readableActive(() => align),
gap: readableActive(() => gap),
loop: readableActive(() => loop), loop: readableActive(() => loop),
dragEnabled: readableActive(() => dragEnabled), dragEnabled: readableActive(() => dragEnabled),
dragThreshold: readableActive(() => dragThreshold), dragThreshold: readableActive(() => dragThreshold),
autoplay: readableActive(() => autoplay), autoplay: readableActive(() => autoplay),
autoplayDelay: readableActive(() => autoplayDelay), autoplayDelay: readableActive(() => autoplayDelay),
playOnInit: readableActive(() => playOnInit),
pauseOnHover: readableActive(() => pauseOnHover), pauseOnHover: readableActive(() => pauseOnHover),
stopOnInteraction: readableActive(() => stopOnInteraction),
stopOnFocusIn: readableActive(() => stopOnFocusIn),
stopOnMouseEnter: readableActive(() => stopOnMouseEnter),
stopOnLastSnap: readableActive(() => stopOnLastSnap),
disabled: readableActive(() => disabled), disabled: readableActive(() => disabled),
ariaLabel: readableActive(() => ariaLabel) ariaLabel: readableActive(() => ariaLabel)
}); });

@ -17,6 +17,9 @@ export type {
CarouselIndicatorGroupProps as IndicatorGroupProps, CarouselIndicatorGroupProps as IndicatorGroupProps,
CarouselIndicatorProps as IndicatorProps, CarouselIndicatorProps as IndicatorProps,
CarouselProviderSnippetProps as ProviderSnippetProps, CarouselProviderSnippetProps as ProviderSnippetProps,
CarouselProviderSnippetProps,
CarouselItemSnippetProps as ItemSnippetProps, CarouselItemSnippetProps as ItemSnippetProps,
CarouselIndicatorSnippetProps as IndicatorSnippetProps CarouselIndicatorSnippetProps as IndicatorSnippetProps,
CarouselAlign,
CarouselApi
} from './types'; } from './types';

@ -14,6 +14,17 @@ export type CarouselProviderSnippetProps = {
canGoPrev: boolean; canGoPrev: boolean;
/** Whether a drag gesture is currently active. */ /** Whether a drag gesture is currently active. */
isDragging: boolean; 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`. */ /** Snippet props exposed by `Carousel.Item`. */
@ -30,16 +41,37 @@ export type CarouselIndicatorSnippetProps = {
// ── Root provider ────────────────────────────────────────────────────────── // ── 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`. * Props for the root `Carousel.Provider`.
* *
* A horizontally or vertically scrollable rail of slides with keyboard, button, * A horizontally or vertically scrollable rail of slides with keyboard, button,
* indicator, drag and autoplay navigation. One slide per page — the component * indicator, drag and autoplay navigation. Supports multi-slides-per-view via
* does not implement multi-slides-per-view mode in this iteration (wrap multiple * `slidesPerView` + `slidesToScroll` (Embla-style group navigation).
* cards in a single `Item` if you need grouped slides).
* *
* Items self-detect count via DOM scoping (no registration required). Indicators * 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< export type CarouselProps = WithChild<
{ {
@ -49,12 +81,31 @@ export type CarouselProps = WithChild<
// Index // Index
/** Current slide index (0-based). Bindable. @default 0 */ /** Current slide index (0-based). Bindable. @default 0 */
value?: number; value?: number;
/**
* Initial slide index when uncontrolled. Ignored when `value` is bound.
* @default 0
*/
defaultValue?: number;
/** Called on every index change. */ /** Called on every index change. */
onValueChange?: OnChangeFn<number>; onValueChange?: OnChangeFn<number>;
// Layout // Layout
/** @default 'horizontal' */ /** @default 'horizontal' */
orientation?: Orientation; 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 * When `true`, going past the last slide wraps to the first and vice
* versa. Autoplay also wraps. @default false * versa. Autoplay also wraps. @default false
@ -74,8 +125,30 @@ export type CarouselProps = WithChild<
autoplay?: boolean; autoplay?: boolean;
/** Autoplay interval in ms. @default 4000 */ /** Autoplay interval in ms. @default 4000 */
autoplayDelay?: number; 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; 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 // Disabled
/** Disable all navigation. @default false */ /** Disable all navigation. @default false */

@ -5,6 +5,7 @@
type CarouselVariant, type CarouselVariant,
type CarouselColor type CarouselColor
} from '$uix/eidos/components/carousel'; } from '$uix/eidos/components/carousel';
import type { CarouselAlign, CarouselProviderSnippetProps } from '$soma/components/carousel';
import { compileMorfo } from '$uix/morfo'; import { compileMorfo } from '$uix/morfo';
import { carouselMorfo } from '@/uix/morfo/components/carousel'; import { carouselMorfo } from '@/uix/morfo/components/carousel';
import { getActiveUix } from '$active-uix'; import { getActiveUix } from '$active-uix';
@ -17,17 +18,40 @@
// ── Live state ──────────────────────────────────────────────────────── // ── Live state ────────────────────────────────────────────────────────
let value = $state(0); let value = $state(0);
let orientation = $state<'horizontal' | 'vertical'>('horizontal'); let orientation = $state<'horizontal' | 'vertical'>('horizontal');
// Layout
let slidesPerView = $state(1);
let slidesToScroll = $state(1);
let align = $state<CarouselAlign>('start');
let gap = $state(0); // in rem; converted to `${gap}rem` when forwarded
let verticalBlockSize = $state(16); // rem
let loop = $state(false); let loop = $state(false);
// Drag
let dragEnabled = $state(true); let dragEnabled = $state(true);
let dragThreshold = $state(0.25);
// Autoplay
let autoplay = $state(false); let autoplay = $state(false);
let autoplayDelay = $state(4000); let autoplayDelay = $state(4000);
let playOnInit = $state(true);
let pauseOnHover = $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 disabled = $state(false);
let size = $state<CarouselSize>('md'); let size = $state<CarouselSize>('md');
let variant = $state<CarouselVariant>('surface'); let variant = $state<CarouselVariant>('surface');
let color = $state<CarouselColor>('primary'); let color = $state<CarouselColor>('primary');
// Imperative-API demo: store the handle exposed via the snippet so the
// footer buttons + keyboard shortcut can drive the carousel.
let api = $state<CarouselProviderSnippetProps['api'] | null>(null);
let isPlaying = $state(false);
type TraceEntry = { event: string; family: string; intent?: string; at: number }; type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]); let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null); let stageRef = $state<HTMLElement | null>(null);
@ -82,14 +106,25 @@
'<Carousel', '<Carousel',
' bind:value', ' bind:value',
orientation !== 'horizontal' && ` orientation="${orientation}"`, orientation !== 'horizontal' && ` orientation="${orientation}"`,
slidesPerView !== 1 && ` slidesPerView={${slidesPerView}}`,
slidesToScroll !== 1 && ` slidesToScroll={${slidesToScroll}}`,
align !== 'start' && ` align="${align}"`,
gap !== 0 && ` gap="${gap}rem"`,
orientation === 'vertical' && verticalBlockSize !== 16 && ` verticalBlockSize="${verticalBlockSize}rem"`,
size !== 'md' && ` size="${size}"`, size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`, variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`, color !== 'primary' && ` color="${color}"`,
loop && ' loop', loop && ' loop',
!dragEnabled && ' dragEnabled={false}', !dragEnabled && ' dragEnabled={false}',
dragEnabled && dragThreshold !== 0.25 && ` dragThreshold={${dragThreshold}}`,
autoplay && ' autoplay', autoplay && ' autoplay',
autoplay && autoplayDelay !== 4000 && ` autoplayDelay={${autoplayDelay}}`, autoplay && autoplayDelay !== 4000 && ` autoplayDelay={${autoplayDelay}}`,
autoplay && !playOnInit && ' playOnInit={false}',
autoplay && !pauseOnHover && ' pauseOnHover={false}', autoplay && !pauseOnHover && ' pauseOnHover={false}',
autoplay && stopOnInteraction && ' stopOnInteraction',
autoplay && !stopOnFocusIn && ' stopOnFocusIn={false}',
autoplay && stopOnMouseEnter && ' stopOnMouseEnter',
autoplay && stopOnLastSnap && ' stopOnLastSnap',
disabled && ' disabled', disabled && ' disabled',
'>', '>',
' <Carousel.Viewport>', ' <Carousel.Viewport>',
@ -146,52 +181,66 @@
<div data-uix-stage> <div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;"> <div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;">
<div style="inline-size: min(36rem, 100%); {orientation === 'vertical' ? 'block-size: 18rem;' : ''}"> <div style="inline-size: min(40rem, 100%);">
<Carousel <Carousel
bind:value bind:value
{orientation} {orientation}
{slidesPerView}
{slidesToScroll}
{align}
gap="{gap}rem"
verticalBlockSize="{verticalBlockSize}rem"
{loop} {loop}
{dragEnabled} {dragEnabled}
{dragThreshold}
{autoplay} {autoplay}
{autoplayDelay} {autoplayDelay}
{playOnInit}
{pauseOnHover} {pauseOnHover}
{stopOnInteraction}
{stopOnFocusIn}
{stopOnMouseEnter}
{stopOnLastSnap}
{disabled} {disabled}
{size} {size}
{variant} {variant}
{color} {color}
> >
<Carousel.Viewport> {#snippet children(snippet: CarouselProviderSnippetProps)}
<Carousel.ItemGroup> {(api = snippet.api, isPlaying = snippet.isPlaying, '')}
{#each slides as slide, i (slide.label)} <Carousel.Viewport>
<Carousel.Item index={i}> <Carousel.ItemGroup>
<div {#each slides as slide, i (slide.label)}
style=" <Carousel.Item index={i}>
display: flex; <div
flex-direction: column; style="
align-items: center; display: flex;
justify-content: center; flex-direction: column;
background: {slide.tone}; align-items: center;
color: var(--color-content-primary); justify-content: center;
padding: var(--space-6) var(--space-4); background: {slide.tone};
border-radius: var(--radius-md); color: var(--color-content-primary);
{orientation === 'vertical' ? 'block-size: 100%;' : 'min-block-size: 12rem;'} padding: var(--space-6) var(--space-4);
user-select: none; border-radius: var(--radius-md);
" {orientation === 'vertical' ? 'block-size: 100%;' : 'min-block-size: 12rem;'}
> user-select: none;
<strong style="font-size: var(--font-size-lg); margin-block-end: var(--space-2);">{slide.label}</strong> "
<span style="font-size: var(--font-size-sm); color: var(--color-content-secondary);">{slide.sub}</span> >
</div> <strong style="font-size: var(--font-size-lg); margin-block-end: var(--space-2);">{slide.label}</strong>
</Carousel.Item> <span style="font-size: var(--font-size-sm); color: var(--color-content-secondary);">{slide.sub}</span>
</div>
</Carousel.Item>
{/each}
</Carousel.ItemGroup>
<Carousel.PrevTrigger />
<Carousel.NextTrigger />
</Carousel.Viewport>
<Carousel.IndicatorGroup>
{#each slides as _, i (i)}
<Carousel.Indicator index={i} />
{/each} {/each}
</Carousel.ItemGroup> </Carousel.IndicatorGroup>
<Carousel.PrevTrigger /> {/snippet}
<Carousel.NextTrigger />
</Carousel.Viewport>
<Carousel.IndicatorGroup>
{#each slides as _, i (i)}
<Carousel.Indicator index={i} />
{/each}
</Carousel.IndicatorGroup>
</Carousel> </Carousel>
</div> </div>
</div> </div>
@ -239,7 +288,7 @@
</p> </p>
<div data-uix-subsection-head> <div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Provider props <span data-uix-layer-badge="soma">soma</span> layout
</div> </div>
<div data-uix-controls> <div data-uix-controls>
<label data-uix-control> <label data-uix-control>
@ -250,6 +299,40 @@
{/each} {/each}
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>slidesPerView</span>
<span data-uix-chips role="radiogroup">
{#each [1, 2, 3, 4] as n (n)}
<button data-uix-chip data-active={slidesPerView === n} onclick={() => (slidesPerView = n)}>{n}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>slidesToScroll</span>
<span data-uix-chips role="radiogroup">
{#each [1, 2, 3] as n (n)}
<button data-uix-chip data-active={slidesToScroll === n} onclick={() => (slidesToScroll = n)}>{n}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each ['start', 'center', 'end'] as a (a)}
<button data-uix-chip data-active={align === a} onclick={() => (align = a as CarouselAlign)}>{a}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>gap (rem)</span>
<input type="range" min="0" max="2" step="0.25" bind:value={gap} />
<span data-uix-control-label-aux>{gap}</span>
</label>
<label data-uix-control>
<span data-uix-control-label>verticalBlockSize (rem)</span>
<input type="range" min="8" max="32" step="1" bind:value={verticalBlockSize} disabled={orientation !== 'vertical'} />
<span data-uix-control-label-aux>{verticalBlockSize}</span>
</label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>loop</span> <span data-uix-control-label>loop</span>
<span data-uix-switch> <span data-uix-switch>
@ -257,6 +340,19 @@
<span data-uix-switch-label>{loop ? 'on' : 'off'}</span> <span data-uix-switch-label>{loop ? 'on' : 'off'}</span>
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> drag
</div>
<div data-uix-controls>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>dragEnabled</span> <span data-uix-control-label>dragEnabled</span>
<span data-uix-switch> <span data-uix-switch>
@ -264,6 +360,17 @@
<span data-uix-switch-label>{dragEnabled ? 'on' : 'off'}</span> <span data-uix-switch-label>{dragEnabled ? 'on' : 'off'}</span>
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>dragThreshold (0–1)</span>
<input type="range" min="0.05" max="0.6" step="0.05" bind:value={dragThreshold} disabled={!dragEnabled} />
<span data-uix-control-label-aux>{dragThreshold.toFixed(2)}</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> autoplay
</div>
<div data-uix-controls>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>autoplay</span> <span data-uix-control-label>autoplay</span>
<span data-uix-switch> <span data-uix-switch>
@ -276,6 +383,13 @@
<input type="range" min="1000" max="8000" step="500" bind:value={autoplayDelay} disabled={!autoplay} /> <input type="range" min="1000" max="8000" step="500" bind:value={autoplayDelay} disabled={!autoplay} />
<span data-uix-control-label-aux>{autoplayDelay}</span> <span data-uix-control-label-aux>{autoplayDelay}</span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>playOnInit</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={playOnInit} disabled={!autoplay} />
<span data-uix-switch-label>{playOnInit ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>pauseOnHover</span> <span data-uix-control-label>pauseOnHover</span>
<span data-uix-switch> <span data-uix-switch>
@ -284,12 +398,50 @@
</span> </span>
</label> </label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>disabled</span> <span data-uix-control-label>stopOnInteraction</span>
<span data-uix-switch> <span data-uix-switch>
<input type="checkbox" bind:checked={disabled} /> <input type="checkbox" bind:checked={stopOnInteraction} disabled={!autoplay} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span> <span data-uix-switch-label>{stopOnInteraction ? 'on' : 'off'}</span>
</span> </span>
</label> </label>
<label data-uix-control>
<span data-uix-control-label>stopOnFocusIn</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={stopOnFocusIn} disabled={!autoplay} />
<span data-uix-switch-label>{stopOnFocusIn ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>stopOnMouseEnter</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={stopOnMouseEnter} disabled={!autoplay} />
<span data-uix-switch-label>{stopOnMouseEnter ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>stopOnLastSnap</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={stopOnLastSnap} disabled={!autoplay || loop} />
<span data-uix-switch-label>{stopOnLastSnap ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> imperative API
<span style="margin-inline-start: auto; color: var(--color-content-secondary); font-size: var(--font-size-xs);">
exposed via the <code>api</code> snippet prop — drive the carousel from external buttons
</span>
</div>
<div data-uix-controls>
<button data-uix-chip onclick={() => api?.scrollPrev()}>scrollPrev()</button>
<button data-uix-chip onclick={() => api?.scrollNext()}>scrollNext()</button>
<button data-uix-chip onclick={() => api?.scrollTo(0)}>scrollTo(0)</button>
<button data-uix-chip onclick={() => api?.scrollTo(slides.length - 1)}>scrollTo(last)</button>
<button data-uix-chip onclick={() => api?.play()} disabled={!autoplay || isPlaying}>play()</button>
<button data-uix-chip onclick={() => api?.pause()} disabled={!autoplay || !isPlaying}>pause()</button>
<button data-uix-chip onclick={() => api?.reset()} disabled={!autoplay}>reset()</button>
<span data-uix-control-label-aux>isPlaying = {isPlaying}</span>
</div> </div>
<div data-uix-subsection-head> <div data-uix-subsection-head>
@ -344,20 +496,31 @@
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead> <thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody> <tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">number</td><td class="default">0</td><td>Current slide index (0-based).</td></tr> <tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">number</td><td class="default">0</td><td>Current slide index (0-based).</td></tr>
<tr><td class="name">defaultValue</td><td class="type">number</td><td class="default">0</td><td>Initial slide index when uncontrolled.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">(i: number) =&gt; void</td><td class="default empty">—</td><td>Called on every index change.</td></tr> <tr><td class="name">onValueChange</td><td class="type">(i: number) =&gt; void</td><td class="default empty">—</td><td>Called on every index change.</td></tr>
<tr><td class="name">orientation</td><td class="type">'horizontal' | 'vertical'</td><td class="default">'horizontal'</td><td>Scroll axis.</td></tr> <tr><td class="name">orientation</td><td class="type">'horizontal' | 'vertical'</td><td class="default">'horizontal'</td><td>Scroll axis.</td></tr>
<tr><td class="name">slidesPerView</td><td class="type">number</td><td class="default">1</td><td>Slides visible at once (each Item basis = 100% / slidesPerView).</td></tr>
<tr><td class="name">slidesToScroll</td><td class="type">number</td><td class="default">1</td><td>Slides advanced per next() / prev().</td></tr>
<tr><td class="name">align</td><td class="type">'start' | 'center' | 'end'</td><td class="default">'start'</td><td>Alignment of active slide(s) within viewport.</td></tr>
<tr><td class="name">gap</td><td class="type">string (CSS length)</td><td class="default">'0px'</td><td>Inline-axis gap between slides.</td></tr>
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>Wrap at edges (also wraps autoplay).</td></tr> <tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>Wrap at edges (also wraps autoplay).</td></tr>
<tr><td class="name">dragEnabled</td><td class="type">boolean</td><td class="default">true</td><td>Allow pointer drag to scrub.</td></tr> <tr><td class="name">dragEnabled</td><td class="type">boolean</td><td class="default">true</td><td>Allow pointer drag to scrub.</td></tr>
<tr><td class="name">dragThreshold</td><td class="type">number</td><td class="default">0.25</td><td>Release fraction past which next slide commits.</td></tr> <tr><td class="name">dragThreshold</td><td class="type">number (0–1)</td><td class="default">0.25</td><td>Release fraction past which next slide commits.</td></tr>
<tr><td class="name">autoplay</td><td class="type">boolean</td><td class="default">false</td><td>Enable timer-driven advance.</td></tr> <tr><td class="name">autoplay</td><td class="type">boolean</td><td class="default">false</td><td>Enable timer-driven advance.</td></tr>
<tr><td class="name">autoplayDelay</td><td class="type">number</td><td class="default">4000</td><td>Interval in milliseconds.</td></tr> <tr><td class="name">autoplayDelay</td><td class="type">number (ms)</td><td class="default">4000</td><td>Interval in milliseconds.</td></tr>
<tr><td class="name">pauseOnHover</td><td class="type">boolean</td><td class="default">true</td><td>Pause autoplay while hovered / focused.</td></tr> <tr><td class="name">playOnInit</td><td class="type">boolean</td><td class="default">true</td><td>Start the autoplay timer on mount.</td></tr>
<tr><td class="name">pauseOnHover</td><td class="type">boolean</td><td class="default">true</td><td>Pause autoplay while hovered.</td></tr>
<tr><td class="name">stopOnInteraction</td><td class="type">boolean</td><td class="default">false</td><td>Latch-stop after user clicks prev/next/indicator or drags.</td></tr>
<tr><td class="name">stopOnFocusIn</td><td class="type">boolean</td><td class="default">true</td><td>Pause while focus is inside root.</td></tr>
<tr><td class="name">stopOnMouseEnter</td><td class="type">boolean</td><td class="default">false</td><td>Pause while cursor over root (Embla parity).</td></tr>
<tr><td class="name">stopOnLastSnap</td><td class="type">boolean</td><td class="default">false</td><td>Latch-stop when last snap reached (loop=false only).</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable all navigation.</td></tr> <tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable all navigation.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Override accessible name (falls back to lang ref).</td></tr> <tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Override accessible name (falls back to lang ref).</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Container chrome.</td></tr> <tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Container chrome.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Cascades to indicator + trigger size.</td></tr> <tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Cascades to indicator + trigger size.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Indicator + focus accent.</td></tr> <tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Indicator + focus accent.</td></tr>
<tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Stretch root inline-size to container.</td></tr> <tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Stretch root inline-size to container.</td></tr>
<tr><td class="name">verticalBlockSize <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string (CSS length)</td><td class="default">'16rem'</td><td>Viewport block-size in vertical orientation.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

Loading…
Cancel
Save

Powered by TurnKey Linux.