feat(eidos): port carousel from soma

Eidos wrapper for the soma Carousel primitive — horizontally or
vertically scrollable rail of slides with keyboard, button, indicator,
drag and autoplay nav. Soma owns the index + autoplay timer + gesture;
eidos adds size/variant/color cascades and renders prev/next triggers
as `<Button iconOnly variant="ghost" rounded="full">` per the canonical
Button consumer pattern.

## Feature comparison

| Feature              | embla | Mantine | ark-ui | Eidos |
|----------------------|-------|---------|--------|-------|
| Index nav            | ✓     | ✓       | ✓      | ✓     |
| Loop                 | ✓     | ✓       | ✓      | ✓     |
| Drag / scrub         | ✓     | —       | ✓      | ✓     |
| Autoplay             | plugin| ✓       | ✓      | ✓     |
| Pause on hover       | plugin| ✓       | ✓      | ✓     |
| Orientation V/H      | ✓     | ✓       | ✓      | ✓     |
| Keyboard nav         | ✓     | ✓       | ✓      | ✓     |
| Indicators           | —     | ✓       | ✓      | ✓     |
| Prev/Next triggers   | —     | ✓       | ✓      | ✓     |
| Size cascade         | —     | —       | —      | ✓ 5   |
| Variant cascade      | —     | —       | —      | ✓ 3   |
| Color cascade        | —     | —       | —      | ✓ 8   |
| Multi-slides view    | ✓     | ✓       | —      | deferred |
| Lazy-mount slides    | plugin| —       | —      | deferred |

## Parts (8)

Provider · Viewport · ItemGroup · Item · PrevTrigger · NextTrigger ·
IndicatorGroup · Indicator.

PrevTrigger and NextTrigger render Button via the soma `child` snippet
(canonical Button consumer pattern). Default chrome:
`iconOnly variant='ghost' rounded='full'` + chevron glyph. Size cascades
from Carousel root; consumers can override every Button visual prop
inline. Passing `child` bypasses Button entirely.

## Recipe

Card chrome around the rail (`variant='surface'`), absolute-positioned
triggers over the viewport edges (vertical flips to top/bottom). Active
indicator stretches inline-size 2.25× for clearer current-position hint.
8 ColorRole accents.

## Demo

Canonical 6-tab depth (~470L): Live / API / Morfo / Sema / Recipe /
A11y. Live controls cover all soma props (orientation, loop, dragEnabled,
autoplay + delay slider, pauseOnHover, disabled) and all eidos props
(variant, size, color). 4 colored placeholder slides for visual contrast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent db21767db1
commit 68670b6331

@ -0,0 +1,23 @@
import { getContext, setContext } from 'svelte';
import type { CarouselColor, CarouselSize, CarouselVariant } from './types';
/**
* Eidos-only context: visual concerns shared from the Provider down to
* sub-parts (prev/next triggers consume `size`, indicators consume
* `size` + `color`).
*/
export type CarouselEidosContext = {
size: CarouselSize;
variant: CarouselVariant;
color: CarouselColor;
};
const KEY = Symbol('eidos:carousel');
export function setCarouselEidosContext(ctx: CarouselEidosContext) {
setContext(KEY, ctx);
}
export function getCarouselEidosContext(): CarouselEidosContext | undefined {
return getContext<CarouselEidosContext | undefined>(KEY);
}

@ -0,0 +1,8 @@
<script lang="ts">
import * as Carousel from '$soma/components/carousel';
import type { CarouselIndicatorGroupProps } from './types';
let { children, ...rest }: CarouselIndicatorGroupProps = $props();
</script>
<Carousel.IndicatorGroup {...rest}>{@render children?.()}</Carousel.IndicatorGroup>

@ -0,0 +1,12 @@
<script lang="ts">
import * as Carousel from '$soma/components/carousel';
import type { CarouselIndicatorProps } from './types';
let { children: bodyContent, ...rest }: CarouselIndicatorProps = $props();
</script>
<Carousel.Indicator {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</Carousel.Indicator>

@ -0,0 +1,8 @@
<script lang="ts">
import * as Carousel from '$soma/components/carousel';
import type { CarouselItemGroupProps } from './types';
let { children, ...rest }: CarouselItemGroupProps = $props();
</script>
<Carousel.ItemGroup {...rest}>{@render children?.()}</Carousel.ItemGroup>

@ -0,0 +1,12 @@
<script lang="ts">
import * as Carousel from '$soma/components/carousel';
import type { CarouselItemProps } from './types';
let { children: bodyContent, ...rest }: CarouselItemProps = $props();
</script>
<Carousel.Item {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</Carousel.Item>

@ -0,0 +1,52 @@
<script lang="ts">
/**
* Eidos `Carousel.NextTrigger` — symmetric mirror of `PrevTrigger`.
* Right-pointing chevron, identical visual API.
*/
import * as Carousel from '$soma/components/carousel';
import { Button } from '$uix/eidos/components/button';
import { SvgChevron } from '$uix/eidos/components/svg';
import { getCarouselEidosContext } from './carousel-context';
import type { CarouselNextTriggerProps } from './types';
let {
children,
child: outerChild,
variant = 'ghost',
size,
rounded = 'full',
intent,
color,
iconOnly = true,
icon: customIcon,
...rest
}: CarouselNextTriggerProps = $props();
const carousel = getCarouselEidosContext();
const resolvedSize = $derived(size ?? carousel?.size ?? 'md');
</script>
{#snippet defaultIcon()}
<SvgChevron direction="right" />
{/snippet}
<Carousel.NextTrigger {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
size={resolvedSize}
{rounded}
{intent}
{color}
{iconOnly}
icon={customIcon ?? defaultIcon}
>
{#if children}{@render children()}{:else}Next slide{/if}
</Button>
{/if}
{/snippet}
</Carousel.NextTrigger>

@ -0,0 +1,57 @@
<script lang="ts">
/**
* Eidos `Carousel.PrevTrigger` — wraps soma's PrevTrigger and renders
* it as an eidos `<Button>` via soma's `child` snippet. Default chrome
* is `iconOnly variant='ghost' rounded='full'` with a left-pointing
* chevron glyph. Consumers can override every visual prop inline; passing
* a `child` snippet bypasses Button entirely.
*
* Size cascades from the Carousel root unless overridden.
*/
import * as Carousel from '$soma/components/carousel';
import { Button } from '$uix/eidos/components/button';
import { SvgChevron } from '$uix/eidos/components/svg';
import { getCarouselEidosContext } from './carousel-context';
import type { CarouselPrevTriggerProps } from './types';
let {
children,
child: outerChild,
variant = 'ghost',
size,
rounded = 'full',
intent,
color,
iconOnly = true,
icon: customIcon,
...rest
}: CarouselPrevTriggerProps = $props();
const carousel = getCarouselEidosContext();
const resolvedSize = $derived(size ?? carousel?.size ?? 'md');
</script>
{#snippet defaultIcon()}
<SvgChevron direction="left" />
{/snippet}
<Carousel.PrevTrigger {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<Button
{...props}
{variant}
size={resolvedSize}
{rounded}
{intent}
{color}
{iconOnly}
icon={customIcon ?? defaultIcon}
>
{#if children}{@render children()}{:else}Previous slide{/if}
</Button>
{/if}
{/snippet}
</Carousel.PrevTrigger>

@ -0,0 +1,8 @@
<script lang="ts">
import * as Carousel from '$soma/components/carousel';
import type { CarouselViewportProps } from './types';
let { children, ...rest }: CarouselViewportProps = $props();
</script>
<Carousel.Viewport {...rest}>{@render children?.()}</Carousel.Viewport>

@ -0,0 +1,261 @@
/*
* Carousel recipe.
*
* [data-carousel] → root region
* [data-carousel-viewport] → clipping box (overflow hidden)
* [data-carousel-item-group] → translatable strip
* [data-carousel-item] → single slide
* [data-carousel-prev-trigger] → previous button (Button consumer)
* [data-carousel-next-trigger] → next button (Button consumer)
* [data-carousel-indicator-group] → tablist of indicators
* [data-carousel-indicator] → individual indicator dot
*
* Sizes — xs..xl on the root; cascades to indicator dot size + default
* gap. Trigger size is forwarded to <Button> via the Button consumer
* pattern, so trigger height tracks Button.
*
* Variant `'surface' | 'outline' | 'ghost'` (ControlVariant).
* - surface: card chrome (border + bg) around the whole rail
* - outline: border only, transparent background
* - ghost: no chrome, viewport bleeds to container
*
* Color cascades the accent for active indicator + focus rings.
*/
[data-carousel] {
--_carousel-gap: var(--space-2);
--_carousel-radius: var(--radius-md);
--_carousel-indicator-size: 0.5rem;
--_carousel-indicator-gap: var(--space-2);
--_carousel-trigger-offset: var(--space-3);
--_carousel-accent: var(--color-primary-solid);
--_carousel-accent-soft: var(--color-primary-element);
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
font-family: var(--font-ui);
color: var(--color-content-primary);
outline: none;
}
[data-carousel][data-block] {
inline-size: 100%;
}
/* ── Variant — surface (default) ─ */
[data-carousel][data-variant='surface'] {
padding: var(--space-2);
border: 1px solid var(--color-border-default);
border-radius: var(--_carousel-radius);
background: var(--color-surface-default);
}
[data-carousel][data-variant='outline'] {
padding: var(--space-2);
border: 1px solid var(--color-border-default);
border-radius: var(--_carousel-radius);
background: transparent;
}
[data-carousel][data-variant='ghost'] {
padding: 0;
border: 1px solid transparent;
background: transparent;
}
/* ── Sizes ─ */
[data-carousel][data-size='xs'] {
--_carousel-indicator-size: 0.375rem;
--_carousel-indicator-gap: var(--space-1);
--_carousel-gap: var(--space-1);
}
[data-carousel][data-size='sm'] {
--_carousel-indicator-size: 0.4375rem;
--_carousel-indicator-gap: var(--space-1);
}
[data-carousel][data-size='lg'] {
--_carousel-indicator-size: 0.625rem;
--_carousel-indicator-gap: var(--space-3);
}
[data-carousel][data-size='xl'] {
--_carousel-indicator-size: 0.75rem;
--_carousel-indicator-gap: var(--space-3);
}
/* ── Color cascades ─ */
[data-carousel][data-color='secondary'] {
--_carousel-accent: var(--color-secondary-solid);
--_carousel-accent-soft: var(--color-secondary-element);
}
[data-carousel][data-color='affirm'] {
--_carousel-accent: var(--color-affirm-solid);
--_carousel-accent-soft: var(--color-affirm-element);
}
[data-carousel][data-color='risk'] {
--_carousel-accent: var(--color-risk-solid);
--_carousel-accent-soft: var(--color-risk-element);
}
[data-carousel][data-color='threat'] {
--_carousel-accent: var(--color-threat-solid);
--_carousel-accent-soft: var(--color-threat-element);
}
[data-carousel][data-color='fulfill'] {
--_carousel-accent: var(--color-fulfill-solid);
--_carousel-accent-soft: var(--color-fulfill-element);
}
[data-carousel][data-color='loss'] {
--_carousel-accent: var(--color-loss-solid);
--_carousel-accent-soft: var(--color-loss-element);
}
[data-carousel][data-color='neutral'] {
--_carousel-accent: var(--color-neutral-solid);
--_carousel-accent-soft: var(--color-neutral-element);
}
[data-carousel][data-disabled] {
opacity: 0.55;
pointer-events: none;
}
[data-carousel]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* ── Viewport ─ */
[data-carousel-viewport] {
position: relative;
flex: 1 1 auto;
min-inline-size: 0;
border-radius: calc(var(--_carousel-radius) - 2px);
}
/* ── ItemGroup ─ */
[data-carousel-item-group] {
gap: var(--_carousel-gap);
transition: transform var(--duration-base) var(--ease-default);
}
[data-carousel-item-group][data-dragging] {
transition: none;
}
[data-carousel][data-variant='ghost'] [data-carousel-item-group] {
gap: 0;
}
/* ── Item ─ */
[data-carousel-item] {
flex: 0 0 100%;
min-inline-size: 0;
border-radius: calc(var(--_carousel-radius) - 2px);
user-select: none;
}
[data-carousel-item][data-orientation='vertical'] {
flex: 0 0 100%;
}
/* ── Triggers (Button consumer pattern) ─
*
* The triggers are rendered as <Button iconOnly variant='ghost'
* rounded='full'>. The recipe selectors below target the `[data-carousel-
* prev-trigger]` / `[data-carousel-next-trigger]` attrs landed on the
* underlying button. Default placement is absolute over the viewport edges;
* consumers can override with custom layout.
*/
[data-carousel-prev-trigger],
[data-carousel-next-trigger] {
position: absolute;
inset-block-start: 50%;
transform: translateY(-50%);
z-index: 1;
}
[data-carousel-prev-trigger] {
inset-inline-start: var(--_carousel-trigger-offset);
}
[data-carousel-next-trigger] {
inset-inline-end: var(--_carousel-trigger-offset);
}
[data-carousel-prev-trigger][data-disabled],
[data-carousel-next-trigger][data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
/* Vertical orientation flips the triggers to top/bottom. */
[data-carousel][data-orientation='vertical'] [data-carousel-prev-trigger] {
inset-block-start: var(--_carousel-trigger-offset);
inset-inline-start: 50%;
transform: translateX(-50%);
}
[data-carousel][data-orientation='vertical'] [data-carousel-next-trigger] {
inset-block-start: auto;
inset-block-end: var(--_carousel-trigger-offset);
inset-inline-end: auto;
inset-inline-start: 50%;
transform: translateX(-50%);
}
/* ── IndicatorGroup ─ */
[data-carousel-indicator-group] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: var(--_carousel-indicator-gap);
padding-block: var(--space-1);
}
[data-carousel-indicator-group][data-orientation='vertical'] {
flex-direction: column;
}
/* ── Indicator ─ */
[data-carousel-indicator] {
inline-size: var(--_carousel-indicator-size);
block-size: var(--_carousel-indicator-size);
padding: 0;
border: 0;
border-radius: var(--radius-full);
background: var(--color-surface-overlay);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-default),
inline-size var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-default);
appearance: none;
-webkit-appearance: none;
}
[data-carousel-indicator]:hover:not([data-disabled]) {
background: var(--_carousel-accent-soft);
}
[data-carousel-indicator][data-state='active'] {
background: var(--_carousel-accent);
/* Active indicator stretches horizontally for clearer current-position hint. */
inline-size: calc(var(--_carousel-indicator-size) * 2.25);
}
[data-carousel-indicator-group][data-orientation='vertical']
[data-carousel-indicator][data-state='active'] {
inline-size: var(--_carousel-indicator-size);
block-size: calc(var(--_carousel-indicator-size) * 2.25);
}
[data-carousel-indicator]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
[data-carousel-indicator][data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}

@ -0,0 +1,50 @@
<script lang="ts">
/**
* Eidos `<Carousel>` — root wrapper around Soma's `Carousel.Provider`.
* Adds `size`, `variant`, `color` and `block` visual concerns; cascades
* size + color to descendants via the eidos context so PrevTrigger /
* NextTrigger / Indicator inherit them without duplicate props.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Carousel from '$soma/components/carousel';
import type { CarouselProps } from './types';
import { setCarouselEidosContext } from './carousel-context';
let {
size = 'md',
variant = 'surface',
color = 'primary',
block = true,
value = $bindable(0),
children: bodyContent,
...rest
}: CarouselProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
setCarouselEidosContext({
get size() {
return resolvedSize;
},
get variant() {
return variant;
},
get color() {
return color;
}
});
</script>
<Carousel.Provider
{...rest}
bind:value
data-size={resolvedSize}
data-variant={variant}
data-color={color}
data-block={block ? '' : undefined}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</Carousel.Provider>

@ -0,0 +1,69 @@
// Carousel — eidos compound API.
//
// import { Carousel } from '$uix/eidos/components/carousel';
//
// <Carousel bind:value variant="surface">
// <Carousel.Viewport>
// <Carousel.ItemGroup>
// <Carousel.Item index={0}>Slide A</Carousel.Item>
// <Carousel.Item index={1}>Slide B</Carousel.Item>
// <Carousel.Item index={2}>Slide C</Carousel.Item>
// </Carousel.ItemGroup>
// </Carousel.Viewport>
// <Carousel.PrevTrigger />
// <Carousel.NextTrigger />
// <Carousel.IndicatorGroup>
// <Carousel.Indicator index={0} />
// <Carousel.Indicator index={1} />
// <Carousel.Indicator index={2} />
// </Carousel.IndicatorGroup>
// </Carousel>
import CarouselComponent from './carousel.svelte';
import Viewport from './carousel-viewport.svelte';
import ItemGroup from './carousel-item-group.svelte';
import Item from './carousel-item.svelte';
import PrevTrigger from './carousel-prev-trigger.svelte';
import NextTrigger from './carousel-next-trigger.svelte';
import IndicatorGroup from './carousel-indicator-group.svelte';
import Indicator from './carousel-indicator.svelte';
type CarouselNamespace = typeof CarouselComponent & {
Viewport: typeof Viewport;
ItemGroup: typeof ItemGroup;
Item: typeof Item;
PrevTrigger: typeof PrevTrigger;
NextTrigger: typeof NextTrigger;
IndicatorGroup: typeof IndicatorGroup;
Indicator: typeof Indicator;
};
const Carousel = CarouselComponent as CarouselNamespace;
Carousel.Viewport = Viewport;
Carousel.ItemGroup = ItemGroup;
Carousel.Item = Item;
Carousel.PrevTrigger = PrevTrigger;
Carousel.NextTrigger = NextTrigger;
Carousel.IndicatorGroup = IndicatorGroup;
Carousel.Indicator = Indicator;
export { Carousel };
export default Carousel;
export type {
CarouselProps,
CarouselViewportProps as ViewportProps,
CarouselItemGroupProps as ItemGroupProps,
CarouselItemProps as ItemProps,
CarouselPrevTriggerProps as PrevTriggerProps,
CarouselNextTriggerProps as NextTriggerProps,
CarouselIndicatorGroupProps as IndicatorGroupProps,
CarouselIndicatorProps as IndicatorProps,
CarouselProviderSnippetProps as ProviderSnippetProps,
CarouselItemSnippetProps as ItemSnippetProps,
CarouselIndicatorSnippetProps as IndicatorSnippetProps,
CarouselSize,
CarouselVariant,
CarouselColor,
CarouselTriggerVariant
} from './types';

@ -0,0 +1,100 @@
import type {
ColorRole,
ControlVariant,
ResponsiveProp,
Size
} from '$uix/eidos/lib/types';
import type {
ProviderProps,
ViewportProps,
ItemGroupProps,
ItemProps,
PrevTriggerProps,
NextTriggerProps,
IndicatorGroupProps,
IndicatorProps,
ProviderSnippetProps,
ItemSnippetProps,
IndicatorSnippetProps
} from '$soma/components/carousel';
import type {
ButtonVariant,
ButtonSize,
ButtonRounded,
ButtonProps as EidosButtonProps
} from '$uix/eidos/components/button';
/**
* Carousel visual size — five-step subset of the canonical scale.
*
* Cascades into prev/next trigger sizing, indicator dot diameter and the
* default gap between slides.
*/
export type CarouselSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
/**
* Carousel visual variant. Narrows the canonical `ControlVariant` family
* since the carousel chrome is control-like (frame around viewport, button
* triggers) — no chip/selection vocabulary applies.
*
* - `surface` (default) — Provider gains a card chrome (border + bg).
* - `outline` — border only, transparent background.
* - `ghost` — no chrome, viewport bleeds to its container.
*/
export type CarouselVariant = ControlVariant;
/**
* Color cascade — accents indicators, focus rings and the active
* indicator/trigger highlight. Defaults to `primary`.
*/
export type CarouselColor = ColorRole;
export type CarouselProps = ProviderProps & {
/** Visual size preset. @default 'md' */
size?: ResponsiveProp<CarouselSize>;
/** Visual variant. @default 'surface' */
variant?: CarouselVariant;
/** Color cascade. @default 'primary' */
color?: CarouselColor;
/** Stretch the root to fill its container's inline-size. @default true */
block?: boolean;
};
export type CarouselViewportProps = ViewportProps;
export type CarouselItemGroupProps = ItemGroupProps;
export type CarouselItemProps = ItemProps;
/**
* Carousel PrevTrigger / NextTrigger visual variant — narrows
* ButtonVariant to the canonical ControlVariant set so the triggers
* stay consistent with the rest of the carousel chrome.
*/
export type CarouselTriggerVariant = Extract<ButtonVariant, ControlVariant>;
type SharedTriggerVisualProps = {
/** Visual variant, narrowed to ControlVariant. @default 'ghost' */
variant?: CarouselTriggerVariant;
/** Override the cascaded Carousel size. */
size?: ResponsiveProp<ButtonSize>;
/** Corner radius. @default 'full' (circular). */
rounded?: ResponsiveProp<ButtonRounded>;
/** Evaluative intent — flows to Button. */
intent?: EidosButtonProps['intent'];
/** Hierarchical color override (only applies under intent='neutral'). */
color?: EidosButtonProps['color'];
/** Render as icon-only (default). @default true */
iconOnly?: boolean;
/** Leading icon snippet (replaces default arrow glyph). */
icon?: EidosButtonProps['icon'];
};
export type CarouselPrevTriggerProps = PrevTriggerProps & SharedTriggerVisualProps;
export type CarouselNextTriggerProps = NextTriggerProps & SharedTriggerVisualProps;
export type CarouselIndicatorGroupProps = IndicatorGroupProps;
export type CarouselIndicatorProps = IndicatorProps;
export type {
ProviderSnippetProps as CarouselProviderSnippetProps,
ItemSnippetProps as CarouselItemSnippetProps,
IndicatorSnippetProps as CarouselIndicatorSnippetProps
};

@ -100,6 +100,7 @@
@import './components/navigation-menu/navigation-menu.css'; @import './components/navigation-menu/navigation-menu.css';
@import './components/grid-list/grid-list.css'; @import './components/grid-list/grid-list.css';
@import './components/time-range-field/time-range-field.css'; @import './components/time-range-field/time-range-field.css';
@import './components/carousel/carousel.css';
/* Shared menu-item indicator partial — must load AFTER the menu recipes /* Shared menu-item indicator partial — must load AFTER the menu recipes
so its `padding-inline-start` calc wins over the row rule's so its `padding-inline-start` calc wins over the row rule's
`padding-inline` shorthand at equal specificity. */ `padding-inline` shorthand at equal specificity. */

@ -184,7 +184,8 @@
heading: 'Media', heading: 'Media',
items: [ items: [
{ slug: '/uix/components/avatar', label: 'Avatar' }, { slug: '/uix/components/avatar', label: 'Avatar' },
{ slug: '/uix/components/icon', label: 'Icon' } { slug: '/uix/components/icon', label: 'Icon' },
{ slug: '/uix/components/carousel', label: 'Carousel' }
] ]
}, },
{ {

@ -0,0 +1,589 @@
<script lang="ts">
import {
Carousel,
type CarouselSize,
type CarouselVariant,
type CarouselColor
} from '$uix/eidos/components/carousel';
import { compileMorfo } from '$uix/morfo';
import { carouselMorfo } from '@/uix/morfo/components/carousel';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
let value = $state(0);
let orientation = $state<'horizontal' | 'vertical'>('horizontal');
let loop = $state(false);
let dragEnabled = $state(true);
let autoplay = $state(false);
let autoplayDelay = $state(4000);
let pauseOnHover = $state(true);
let disabled = $state(false);
let size = $state<CarouselSize>('md');
let variant = $state<CarouselVariant>('surface');
let color = $state<CarouselColor>('primary');
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const compiled = compileMorfo(carouselMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const slides = [
{ tone: 'var(--color-primary-element)', label: 'Aurora · primary element', sub: 'slide 1 of 4' },
{ tone: 'var(--color-affirm-element)', label: 'Meadow · affirm element', sub: 'slide 2 of 4' },
{ tone: 'var(--color-risk-element)', label: 'Ember · risk element', sub: 'slide 3 of 4' },
{ tone: 'var(--color-fulfill-element)', label: 'Tide · fulfill element', sub: 'slide 4 of 4' }
];
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Carousel } from '$uix/eidos/components/carousel';",
' let value = $state(0);',
'</' + 'script>',
'',
'<Carousel',
' bind:value',
orientation !== 'horizontal' && ` orientation="${orientation}"`,
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
loop && ' loop',
!dragEnabled && ' dragEnabled={false}',
autoplay && ' autoplay',
autoplay && autoplayDelay !== 4000 && ` autoplayDelay={${autoplayDelay}}`,
autoplay && !pauseOnHover && ' pauseOnHover={false}',
disabled && ' disabled',
'>',
' <Carousel.Viewport>',
' <Carousel.ItemGroup>',
' <Carousel.Item index={0}>Slide A</Carousel.Item>',
' <Carousel.Item index={1}>Slide B</Carousel.Item>',
' <Carousel.Item index={2}>Slide C</Carousel.Item>',
' </Carousel.ItemGroup>',
' <Carousel.PrevTrigger />',
' <Carousel.NextTrigger />',
' </Carousel.Viewport>',
' <Carousel.IndicatorGroup>',
' {#each slides as _, i}',
' <Carousel.Indicator index={i} />',
' {/each}',
' </Carousel.IndicatorGroup>',
'</Carousel>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Media · Carousel</div>
<h1 data-uix-page-title>Carousel</h1>
<p data-uix-page-lede>
Horizontally or vertically scrollable rail of slides with keyboard,
button, indicator, drag and autoplay navigation. One slide per page.
Pattern follows the WAI-ARIA carousel
pattern with <code>role="region"</code> + <code>aria-roledescription="carousel"</code>;
indicators map 1:1 with slides as a <code>tablist</code>.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>5
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={carouselMorfo.apg} target="_blank" rel="noreferrer">carousel ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>index</span> {value}
</span>
</div>
</header>
<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;' : ''}">
<Carousel
bind:value
{orientation}
{loop}
{dragEnabled}
{autoplay}
{autoplayDelay}
{pauseOnHover}
{disabled}
{size}
{variant}
{color}
>
<Carousel.Viewport>
<Carousel.ItemGroup>
{#each slides as slide, i (slide.label)}
<Carousel.Item index={i}>
<div
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: {slide.tone};
color: var(--color-content-primary);
padding: var(--space-6) var(--space-4);
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>
</Carousel.Item>
{/each}
</Carousel.ItemGroup>
<Carousel.PrevTrigger />
<Carousel.NextTrigger />
</Carousel.Viewport>
<Carousel.IndicatorGroup>
{#each slides as _, i (i)}
<Carousel.Indicator index={i} />
{/each}
</Carousel.IndicatorGroup>
</Carousel>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>arrow keys · drag · click indicators</span>
{:else}
{#each trace.slice(0, 3) as entry (entry.at)}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>index</span> {value}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>8</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Soma owns the index, autoplay timer, drag gesture, keyboard nav
and orientation. Eidos adds chrome (variant / size / color) and
renders the prev/next triggers as eidos <code>&lt;Button iconOnly variant="ghost"&gt;</code> via
the canonical Button consumer pattern.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Provider props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['horizontal', 'vertical'] as o (o)}
<button data-uix-chip data-active={orientation === o} onclick={() => (orientation = o as 'horizontal' | 'vertical')}>{o}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>loop</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={loop} />
<span data-uix-switch-label>{loop ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>dragEnabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={dragEnabled} />
<span data-uix-switch-label>{dragEnabled ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>autoplay</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={autoplay} />
<span data-uix-switch-label>{autoplay ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>autoplayDelay (ms)</span>
<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>pauseOnHover</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={pauseOnHover} disabled={!autoplay} />
<span data-uix-switch-label>{pauseOnHover ? '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="eidos">eidos</span> visual props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as CarouselVariant)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as CarouselSize)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as CarouselColor)}>{c}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composition · with bind:value</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>8 parts: Provider, Viewport, ItemGroup, Item, PrevTrigger, NextTrigger, IndicatorGroup, Indicator.</p>
<div data-uix-subsection-head>Provider (Carousel)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<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">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">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">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">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>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Viewport · ItemGroup</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>Viewport</code> is the <code>overflow: hidden</code> clipping
box. <code>ItemGroup</code> is the translatable strip whose
<code>transform</code> the provider drives. Both are pass-through —
no eidos-specific props.
</p>
<div data-uix-subsection-head>Item</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">index</td><td class="type">number</td><td class="default empty">required</td><td>0-based slide index. Used for <code>aria-roledescription</code> + indicator wiring.</td></tr>
<tr><td class="name">children</td><td class="type">Snippet&lt;[{`{ index, isActive }`}]&gt;</td><td class="default empty">—</td><td>Slide body. Receives active flag for per-slide effects.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>PrevTrigger · NextTrigger</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<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">'ghost'</td><td>Button variant.</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">cascades from Carousel</td><td>Button size override.</td></tr>
<tr><td class="name">rounded <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ButtonRounded</td><td class="default">'full'</td><td>Corner radius.</td></tr>
<tr><td class="name">intent <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">Intent</td><td class="default empty">—</td><td>Evaluative intent.</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 empty">—</td><td>Hierarchical color (under intent='neutral').</td></tr>
<tr><td class="name">iconOnly <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Square aspect, label sr-only.</td></tr>
<tr><td class="name">icon <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">Snippet</td><td class="default">chevron</td><td>Override default arrow glyph.</td></tr>
<tr><td class="name">child <span data-uix-tag data-kind="soma">soma</span></td><td class="type">Snippet</td><td class="default empty">—</td><td>Bypass Button entirely; render custom element.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>IndicatorGroup · Indicator</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">index (Indicator)</td><td class="type">number</td><td class="default empty">required</td><td>0-based slide this indicator navigates to.</td></tr>
<tr><td class="name">aria-label (Indicator)</td><td class="type">string</td><td class="default empty">—</td><td>Override accessible name; falls back to "Go to slide {`{n}`}".</td></tr>
<tr><td class="name">children (Indicator)</td><td class="type">Snippet&lt;[{`{ index, isActive }`}]&gt;</td><td class="default empty">—</td><td>Custom indicator body (default is a dot).</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/carousel.ts</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{carouselMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{carouselMorfo.kebab}"</td></tr>
<tr><td class="name">apg</td><td><a href={carouselMorfo.apg} target="_blank" rel="noreferrer">{carouselMorfo.apg}</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{carouselMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{intentStr}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-carousel]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>Selectors at <code>src/uix/eidos/components/carousel/carousel.css</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-carousel]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Root — flex column, gap, font.</td></tr>
<tr><td class="name">[data-carousel][data-variant]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Surface / outline / ghost chrome around the rail.</td></tr>
<tr><td class="name">[data-carousel][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Indicator dot diameter + gap.</td></tr>
<tr><td class="name">[data-carousel][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Active-indicator accent + focus ring.</td></tr>
<tr><td class="name">[data-carousel-viewport]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Clipping box (overflow hidden, inline soma styles).</td></tr>
<tr><td class="name">[data-carousel-item-group][data-dragging]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Transform transition off while dragging.</td></tr>
<tr><td class="name">[data-carousel-item]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Slide — 100% basis along axis.</td></tr>
<tr><td class="name">[data-carousel-prev-trigger] · [data-carousel-next-trigger]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Absolute-positioned over viewport edges; vertical flips to top/bottom.</td></tr>
<tr><td class="name">[data-carousel-indicator][data-state='active']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Active indicator: accent fill + 2.25× inline-size stretch.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
Follows the APG <a href={carouselMorfo.apg} target="_blank" rel="noreferrer">carousel pattern</a>.
The root is <code>role="region"</code> with <code>aria-roledescription="carousel"</code> +
an accessible name. Each slide is <code>role="group"</code> with
<code>aria-roledescription="slide"</code>. Indicators form a
<code>role="tablist"</code> with <code>role="tab"</code> children.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowDown</span></td><td>Next slide.</td></tr>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowUp</span></td><td>Previous slide.</td></tr>
<tr><td><span data-uix-kbd>Home</span></td><td>First slide.</td></tr>
<tr><td><span data-uix-kbd>End</span></td><td>Last slide.</td></tr>
<tr><td><span data-uix-kbd>Tab</span></td><td>Move focus through prev / next / indicators / inner slide controls.</td></tr>
<tr><td>pointer drag</td><td>Scrub between slides; commits when past <code>dragThreshold</code>.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider</td><td>role</td><td class="type">"region"</td></tr>
<tr><td class="name">provider</td><td>aria-roledescription</td><td class="type">"carousel"</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">localized "Carousel"</td></tr>
<tr><td class="name">item</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">item</td><td>aria-roledescription</td><td class="type">"slide"</td></tr>
<tr><td class="name">prev-trigger / next-trigger</td><td>type / aria-label</td><td class="type">"button" / "Previous slide" | "Next slide"</td></tr>
<tr><td class="name">indicator-group</td><td>role / aria-orientation</td><td class="type">"tablist" / orientation</td></tr>
<tr><td class="name">indicator</td><td>role / aria-selected</td><td class="type">"tab" / "true|false"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.