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-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%;
}

@ -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 @@
<Carousel.Provider
{...rest}
bind:value
style={composedStyle}
data-size={resolvedSize}
data-variant={variant}
data-color={color}

@ -58,6 +58,15 @@ export type CarouselProps = ProviderProps & {
color?: CarouselColor;
/** Stretch the root to fill its container's inline-size. @default true */
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;

@ -77,12 +77,21 @@ function carouselOpts(root = document.createElement('section')) {
ref: state<HTMLElement | null>(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<string | undefined>(undefined),
onValueChange: state<((value: number) => void) | undefined>(undefined)

@ -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<number> | 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<typeof Gesture.base>;
@ -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<HTMLDivElement>) => {
@ -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<string | undefined> = 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 ────────────────────────────────────────────────────────────

@ -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)
});

@ -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';

@ -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<number>;
// 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 */

@ -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<CarouselAlign>('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<CarouselSize>('md');
let variant = $state<CarouselVariant>('surface');
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 };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
@ -82,14 +106,25 @@
'<Carousel',
' bind:value',
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}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
loop && ' loop',
!dragEnabled && ' dragEnabled={false}',
dragEnabled && dragThreshold !== 0.25 && ` dragThreshold={${dragThreshold}}`,
autoplay && ' autoplay',
autoplay && autoplayDelay !== 4000 && ` autoplayDelay={${autoplayDelay}}`,
autoplay && !playOnInit && ' playOnInit={false}',
autoplay && !pauseOnHover && ' pauseOnHover={false}',
autoplay && stopOnInteraction && ' stopOnInteraction',
autoplay && !stopOnFocusIn && ' stopOnFocusIn={false}',
autoplay && stopOnMouseEnter && ' stopOnMouseEnter',
autoplay && stopOnLastSnap && ' stopOnLastSnap',
disabled && ' disabled',
'>',
' <Carousel.Viewport>',
@ -146,20 +181,33 @@
<div data-uix-stage>
<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
bind:value
{orientation}
{slidesPerView}
{slidesToScroll}
{align}
gap="{gap}rem"
verticalBlockSize="{verticalBlockSize}rem"
{loop}
{dragEnabled}
{dragThreshold}
{autoplay}
{autoplayDelay}
{playOnInit}
{pauseOnHover}
{stopOnInteraction}
{stopOnFocusIn}
{stopOnMouseEnter}
{stopOnLastSnap}
{disabled}
{size}
{variant}
{color}
>
{#snippet children(snippet: CarouselProviderSnippetProps)}
{(api = snippet.api, isPlaying = snippet.isPlaying, '')}
<Carousel.Viewport>
<Carousel.ItemGroup>
{#each slides as slide, i (slide.label)}
@ -192,6 +240,7 @@
<Carousel.Indicator index={i} />
{/each}
</Carousel.IndicatorGroup>
{/snippet}
</Carousel>
</div>
</div>
@ -239,7 +288,7 @@
</p>
<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 data-uix-controls>
<label data-uix-control>
@ -250,6 +299,40 @@
{/each}
</span>
</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>
<span data-uix-control-label>loop</span>
<span data-uix-switch>
@ -257,6 +340,19 @@
<span data-uix-switch-label>{loop ? 'on' : 'off'}</span>
</span>
</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>
<span data-uix-control-label>dragEnabled</span>
<span data-uix-switch>
@ -264,6 +360,17 @@
<span data-uix-switch-label>{dragEnabled ? 'on' : 'off'}</span>
</span>
</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>
<span data-uix-control-label>autoplay</span>
<span data-uix-switch>
@ -276,6 +383,13 @@
<input type="range" min="1000" max="8000" step="500" bind:value={autoplayDelay} disabled={!autoplay} />
<span data-uix-control-label-aux>{autoplayDelay}</span>
</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>
<span data-uix-control-label>pauseOnHover</span>
<span data-uix-switch>
@ -284,12 +398,50 @@
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-control-label>stopOnInteraction</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
<input type="checkbox" bind:checked={stopOnInteraction} disabled={!autoplay} />
<span data-uix-switch-label>{stopOnInteraction ? 'on' : 'off'}</span>
</span>
</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 data-uix-subsection-head>
@ -344,20 +496,31 @@
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<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">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">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">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">autoplayDelay</td><td class="type">number</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">autoplayDelay</td><td class="type">number (ms)</td><td class="default">4000</td><td>Interval in milliseconds.</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">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">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">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>
</table>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.