fix(carousel): :active press feedback was replacing centering transform

Bug behind the user's "el botón se desplaza en el eje contrario al
orientation" report. The Button recipe sets

  [data-button]:active { transform: translateY(1px); }

on press to simulate a physical button push. Carousel prev/next triggers
absolute-position via:

  [data-carousel-prev-trigger] { transform: translateY(-50%); }     (horiz)
  [data-carousel][data-orientation='vertical']
    [data-carousel-prev-trigger] { transform: translateX(-50%); }   (vert)

CSS `transform` is a single property — Button's :active value
REPLACES the carousel's centering transform, so on press the trigger
jumped off its anchor: in horizontal it lost vertical centering, in
vertical it lost horizontal centering AND moved 1px down (wrong axis
relative to slide travel).

Override `:active` on the carousel triggers to compose both transforms
on the appropriate axis:

  horizontal: translateY(calc(-50% + 1px))
  vertical:   translateX(calc(-50% + 1px))

Now press feedback shifts the trigger 1px along the centering axis
without breaking the absolute anchor.

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

@ -0,0 +1,61 @@
# Eidos Carousel
Carousel es el wrapper visual de Eidos sobre el carrusel headless de Soma. Soma
posee indice, drag, teclado, autoplay, ARIA y disabled state; Eidos anade
chrome, tamano, color, botones de flecha con `Button` y receta CSS.
## Baseline Air
En la rama `morfo-runtime` no existe un `src/uix/air/components/carousel` visual
separado. La baseline local anterior vive en
`src/uix/soma/components/carousel`: root region, viewport, item-group, items,
prev/next triggers e indicadores.
Eidos conserva esa anatomia, pero la presenta como componente compound
`<Carousel>` con hijos explicitos (`Viewport`, `ItemGroup`, `Item`,
`PrevTrigger`, `NextTrigger`, `IndicatorGroup`, `Indicator`) y anade props
visuales (`size`, `variant`, `color`, `block`, `verticalBlockSize`).
## Comparativa
| Capacidad | APG / WAI-ARIA | Ark UI | shadcn-svelte / Embla | UIX |
| --- | --- | --- | --- | --- |
| Root con nombre accesible | Recomienda `region` + `aria-roledescription` | Root + partes | Root visual + Embla API | Soma/Morfo |
| Prev/Next como botones nativos | Recomendado | `PrevTrigger` / `NextTrigger` | `Previous` / `Next` | Soma triggers + Eidos Button |
| Indicadores | Botones/tabs de seleccion | `IndicatorGroup` / `Indicator` | Dots via composicion | Soma indicators |
| Drag/swipe | No prescribe motor | Incluido | Embla | Soma `Gesture.base()` |
| Autoplay + pausa | Debe ser controlable | `autoplay`, pause cases | Plugin Embla | Soma timer + stop/pause props |
| Visual styling | Fuera de scope | Headless/data attrs | Tailwind recipe | Eidos recipe tokens |
Fuentes:
- [WAI-ARIA APG Carousel](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/)
- [Ark UI Carousel](https://ark-ui.com/docs/components/carousel)
- [shadcn-svelte Carousel](https://shadcn-svelte.com/docs/components/carousel)
- [Embla Carousel Svelte](https://www.embla-carousel.com/docs/get-started/svelte/)
## Decisiones
- Los triggers de Eidos renderizan `Button iconOnly` por defecto. El icono es
decorativo (`aria-hidden`) y no debe ser target de puntero; el click pertenece
al boton nativo que trae el handler de Soma.
- `PrevTrigger`, `NextTrigger` e `Indicator` llaman a la API publica
`scrollPrev`, `scrollNext` y `scrollTo`, para que `stopOnInteraction` trate
todos los clicks como interaccion real de usuario.
- `Indicator` hereda el archetype global `indicator`, que por defecto es
decorativo y usa `pointer-events: none`. Carousel lo sobreescribe a
`pointer-events: auto` porque sus indicadores son botones/tab stops reales.
- `slidesPerView`, `slidesToScroll`, `align` y `gap` viven en Soma porque
alteran geometria y bounds. Eidos solo consume el resultado visual mediante
`data-*` y estilos.
- `verticalBlockSize` es Eidos-only: el eje vertical necesita una altura
acotada para que el viewport pueda recortar, pero no cambia el contrato ARIA.
## Gaps
| Gap | Disposicion | Detalle |
| --- | --- | --- |
| Pause/play trigger publico dentro del componente | diferir | La API imperativa ya expone `play`, `pause` y `reset`; una parte visual requiere Morfo/Soma. |
| Labels traducidos para slide/indicator generados por indice | implementar en Soma | El README de Soma ya documenta que parte del texto se arma en JS. |
| Tests browser-level permanentes | implementar | El fallo de click sobre SVG se verifico manualmente con Playwright; conviene convertirlo en test visual si el harness lo permite. |
| Motion attrs por direccion | diferir | Seria un extra visual para animaciones internas de slide. |

@ -97,6 +97,7 @@
align-self: center; align-self: center;
} }
[data-carousel][data-block] { [data-carousel][data-block] {
inline-size: 100%; inline-size: 100%;
} }
@ -245,6 +246,26 @@
cursor: not-allowed; cursor: not-allowed;
} }
/*
* Compose the Button `:active` press feedback with the trigger's
* centering transform. Without these overrides Button's recipe sets
* `transform: translateY(1px)` on press which REPLACES our centering
* `translateY(-50%)` (or `translateX(-50%)` in vertical), making the
* trigger jump entirely off its anchor when clicked. The override
* keeps the centering AND adds the 1px press shift on the same axis.
*/
[data-carousel-prev-trigger]:active:not([data-disabled]):not([data-loading]),
[data-carousel-next-trigger]:active:not([data-disabled]):not([data-loading]) {
transform: translateY(calc(-50% + 1px));
}
[data-carousel][data-orientation='vertical']
[data-carousel-prev-trigger]:active:not([data-disabled]):not([data-loading]),
[data-carousel][data-orientation='vertical']
[data-carousel-next-trigger]:active:not([data-disabled]):not([data-loading]) {
transform: translateX(calc(-50% + 1px));
}
/* Vertical orientation flips the triggers to top/bottom. */ /* Vertical orientation flips the triggers to top/bottom. */
[data-carousel][data-orientation='vertical'] [data-carousel-prev-trigger] { [data-carousel][data-orientation='vertical'] [data-carousel-prev-trigger] {
inset-block-start: var(--_carousel-trigger-offset); inset-block-start: var(--_carousel-trigger-offset);
@ -283,6 +304,7 @@
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--color-surface-overlay); background: var(--color-surface-overlay);
cursor: pointer; cursor: pointer;
pointer-events: auto;
transition: transition:
background var(--duration-fast) var(--ease-default), background var(--duration-fast) var(--ease-default),
inline-size var(--duration-fast) var(--ease-default), inline-size var(--duration-fast) var(--ease-default),

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { import {
readableActive, readableActive,
writableActive writableActive
@ -47,7 +48,7 @@
// `defaultValue` on first read. Snapshot the initial defaultValue so the // `defaultValue` on first read. Snapshot the initial defaultValue so the
// subsequent reads inside the writableActive closure don't bind reactively // subsequent reads inside the writableActive closure don't bind reactively
// to the prop (consumers shouldn't change defaultValue after mount). // to the prop (consumers shouldn't change defaultValue after mount).
const initialDefault = defaultValue; const initialDefault = untrack(() => defaultValue);
if (value === undefined) value = initialDefault; if (value === undefined) value = initialDefault;
const state = CarouselProvider.create({ const state = CarouselProvider.create({

@ -108,6 +108,58 @@
{ tone: 'var(--color-fulfill-element)', label: 'Tide · fulfill element', sub: 'slide 4 of 4' } { tone: 'var(--color-fulfill-element)', label: 'Tide · fulfill element', sub: 'slide 4 of 4' }
]; ];
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as Carousel from '$soma/components/carousel';",
' let value = $state(0);',
" const slides = ['Slide A', 'Slide B', 'Slide C'];",
'</' + 'script>',
'',
'<Carousel.Provider',
' bind:value',
orientation !== 'horizontal' && ` orientation="${orientation}"`,
slidesPerView !== 1 && ` slidesPerView={${slidesPerView}}`,
slidesToScroll !== 1 && ` slidesToScroll={${slidesToScroll}}`,
align !== 'start' && ` align="${align}"`,
gap !== 0 && ` gap="${gap}rem"`,
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',
dir !== 'ltr' && ` dir="${dir}"`,
locale !== 'en-US' && ` locale="${locale}"`,
'>',
' {#snippet children({ api, isPlaying })}',
' <Carousel.Viewport>',
' <Carousel.ItemGroup>',
' {#each slides as slide, i}',
' <Carousel.Item index={i}>{slide}</Carousel.Item>',
' {/each}',
' </Carousel.ItemGroup>',
' </Carousel.Viewport>',
' <Carousel.PrevTrigger />',
' <Carousel.NextTrigger />',
' <Carousel.IndicatorGroup>',
' {#each slides as _, i}',
' <Carousel.Indicator index={i} />',
' {/each}',
' </Carousel.IndicatorGroup>',
' {/snippet}',
'</Carousel.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived( const eidosSnippet = $derived(
[ [
"<script lang='ts'>", "<script lang='ts'>",
@ -517,6 +569,15 @@
</label> </label>
</div> </div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · ARIA, state and gesture only</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code> <div data-uix-code>
<div data-uix-code-head> <div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span> <span data-uix-layer-badge="eidos">eidos</span>

Loading…
Cancel
Save

Powered by TurnKey Linux.