Elementos
Carousel
Un carrusel muestra las diapositivas de una en una: las fotos de un producto, los pasos de un onboarding, un banner que avanza solo, una fila de tarjetas. Carousel se encarga del comportamiento: qué diapositiva se muestra, el deslizamiento entre ellas, los controles de anterior y siguiente, una paginación, las teclas de flecha, dar la vuelta por los extremos y lo que oyen los lectores de pantalla. Sus partes lo encuentran mediante el contexto, de modo que la app las organiza libremente en su propio layout. El aspecto es de la app: no trae colores, tamaños, flechas, puntos ni etiquetas propios.
import { mount, signal, Text, View } from "nsh";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPagination, CarouselPaginationItem, CarouselPrevious } from "nsh/base";
import { desktop, focusRing, hoverFill, Icon, Log, Split, tint, ui } from "./palette";
const places: { name: string; detail: string; from: string; to: string }[] = [
{ name: "Lisbon", detail: "Tiles, trams and the river light", from: "#3d5a73", to: "#8fb3c9" },
{ name: "Kyoto", detail: "Temples, moss gardens, quiet lanes", from: "#5a3d4a", to: "#c98fa6" },
{ name: "Oaxaca", detail: "Markets, mole and mezcal", from: "#6b4a2f", to: "#d1a06b" },
{ name: "Tromsø", detail: "Fjords under the northern lights", from: "#2f4a4f", to: "#6bb3a8" },
{ name: "Marrakesh", detail: "Riads, souks and courtyards", from: "#73402f", to: "#d9875b" },
];
function App() {
const shown = signal(0);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const arrow = {
style: { width: desktop ? 28 : 34, height: desktop ? 28 : 34, borderRadius: 999, alignItems: "center", justifyContent: "center", ...ui().raised } as const,
class: "transition-opacity active:opacity-70 disabled:opacity-30 motion-reduce:transition-none",
hoverStyle: hoverFill(),
focusStyle: focusRing(2),
};
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>carousel</Text>
<Text style={{ color: ui().muted }}>
{desktop
? "Scroll with the touchpad, click the arrows or a dot, or focus it and press ← →; after the last place comes the first"
: "Swipe the cards, or tap the arrows or a dot; after the last place comes the first"}
</Text>
<Split gap={14} inspector={<Log title={`onChange · ${places[shown()].name}`} lines={events()} minHeight={76} />}>
{/* Five cards at 85% span more than the view and a card: the loop is seamless. */}
<Carousel
looping
accessibilityLabel="Places to visit"
onChange={({ index }) => {
shown.set(index);
log(`index: ${index} (${places[index].name})`);
}}
style={{ gap: 12, borderRadius: 14 }}
focusStyle={focusRing(4)}
>
{/* Each card is 85% of the view: the next one peeks in. */}
<CarouselContent style={{ height: desktop ? 150 : 170, gap: 10 }}>
<Each of={places}>
{(p, i) => (
<CarouselItem
accessibilityLabel={`${i() + 1} of ${places.length}: ${p().name}`}
style={{
width: "85%",
height: "100%",
padding: 16,
justifyContent: "flex-end",
gap: 2,
borderRadius: 14,
borderCurve: "continuous",
backgroundColor: p().from,
backgroundImage: `linear-gradient(135deg, ${p().from} 0%, ${p().to} 100%)`,
}}
>
<Text style={{ fontSize: 20, fontWeight: "700", color: "#ffffff" }}>{p().name}</Text>
<Text style={{ fontSize: 13, color: tint("#ffffff", 0.82) }}>{p().detail}</Text>
</CarouselItem>
)}
</Each>
</CarouselContent>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<CarouselPrevious accessibilityLabel="Previous place" {...arrow}>
<Icon name="back" size={14} tint={ui().text} />
</CarouselPrevious>
{/* The dots: a pill springs to the one of the card shown. */}
<CarouselPagination
accessibilityLabel="Choose a place"
style={{ flex: 1, flexDirection: "row", justifyContent: "center", alignItems: "center" }}
indicatorStyle={{ top: 7, bottom: 7, marginHorizontal: 2, borderRadius: 4, backgroundColor: ui().accent }}
>
<Each of={places}>
{(p, i) => (
<CarouselPaginationItem
index={i()}
accessibilityLabel={p().name}
class="group"
style={{ width: 20, height: 20, alignItems: "center", justifyContent: "center", borderRadius: 10 }}
focusStyle={focusRing()}
>
<View
class="opacity-60 group-hover:opacity-100 group-selected:opacity-0 transition-opacity motion-reduce:transition-none"
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: tint(ui().text, 0.4) }}
/>
</CarouselPaginationItem>
)}
</Each>
</CarouselPagination>
<CarouselNext accessibilityLabel="Next place" {...arrow}>
<Icon name="chevron" size={14} tint={ui().text} />
</CarouselNext>
</View>
</Carousel>
</Split>
</View>
);
}
mount(App);El contenido es una scroll view nativa. Un dedo, la rueda y el touchpad mueven las diapositivas en el hilo principal de la plataforma, en el mismo fotograma que el puntero, y al soltar el contenido se ajusta a la diapositiva más cercana, una diapositiva por gesto de lanzamiento. Cuando se detiene, el carrusel informa de la diapositiva con onChange. Los controles, la paginación, las teclas y el index de la app desplazan el contenido de forma nativa hasta la diapositiva, con animación.
use nsh_base::{
carousel, carousel_content, carousel_item, carousel_next, carousel_pagination,
carousel_pagination_item, carousel_previous, theme,
};
use nsh_core::prelude::*;
fn photos() -> impl IntoElement {
let index = signal(0u32);
let names = ["Harbor", "Dunes", "Glacier"];
carousel()
.index(move || Some(index.get()))
.on_change(move |i: &u32| index.set(*i))
.accessibility_label("Photos")
.gap_3()
.child(
carousel_content().w_full().h(px(180.0)).gap_3().child(
names
.iter()
.enumerate()
.map(|(i, name)| {
carousel_item()
.accessibility_label(format!("{} of 3: {name}", i + 1))
.w_full()
.rounded(move || theme().radius)
.bg(move || theme().muted)
.items_center()
.justify_center()
.child(text(*name))
})
.collect::<Vec<_>>(),
),
)
.child(
view()
.flex_row()
.items_center()
.gap_2()
.child(carousel_previous().accessibility_label("Previous photo").child(text("‹")))
.child(
carousel_pagination()
.flex_row()
.gap_1()
.child(
(0..3)
.map(|i| {
carousel_pagination_item()
.index(i)
.accessibility_label(format!("Photo {}", i + 1))
.size_2()
.rounded_full()
.bg(move || theme().border)
.selected_style(|s| s.bg(move || theme().primary))
})
.collect::<Vec<_>>(),
),
)
.child(carousel_next().accessibility_label("Next photo").child(text("›"))),
)
}import { mount, signal, Text, View } from "nsh";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPagination,
CarouselPaginationItem,
CarouselPrevious,
} from "nsh/base";
const NAMES = ["Harbor", "Dunes", "Glacier"];
function App() {
const index = signal(0);
return (
<Carousel index={index()} onChange={({ index: next }) => index.set(next)} accessibilityLabel="Photos" class="gap-3">
<CarouselContent class="h-[180px] w-full gap-3">
{NAMES.map((name, i) => (
<CarouselItem
accessibilityLabel={`${i + 1} of ${NAMES.length}: ${name}`}
class="w-full items-center justify-center rounded-(--radius) bg-muted"
>
<Text>{name}</Text>
</CarouselItem>
))}
</CarouselContent>
<View class="flex-row items-center gap-2">
<CarouselPrevious accessibilityLabel="Previous photo" class="disabled:opacity-40">
<Text>‹</Text>
</CarouselPrevious>
<CarouselPagination class="flex-row gap-1">
{NAMES.map((_, i) => (
<CarouselPaginationItem
index={i}
accessibilityLabel={`Photo ${i + 1}`}
class="size-2 rounded-full bg-border selected:bg-primary"
/>
))}
</CarouselPagination>
<CarouselNext accessibilityLabel="Next photo" class="disabled:opacity-40">
<Text>›</Text>
</CarouselNext>
</View>
</Carousel>
);
}
mount(App);Carousel y sus partes provienen de nsh/base, el crate nsh-base de Rust. La diapositiva mostrada es el index de la app: un deslizamiento, un control o una tecla informa de la siguiente con onChange, y el carrusel se desplaza hasta el index que la app devuelve. La diapositiva en la que se detiene un deslizamiento se muestra hasta que la app responde a su onChange: permanece cuando la app la devuelve como index, y el contenido vuelve con efecto de rebote a la diapositiva de la app, con animación, cuando la app mantiene el index que tenía. Sin index el carrusel mantiene el suyo, a partir de defaultIndex. En Rust, carousel(), carousel_content(), carousel_item(), carousel_previous(), carousel_next(), carousel_pagination() y carousel_pagination_item() crean las partes, con un setter por prop (.index(..)), un listener por evento (.on_change(..)), los métodos de estilo tipados y .child(..) para su contenido.
#Props
#Carousel
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
index |
.index(..) |
number |
ninguno | La diapositiva mostrada, desde 0, limitada a las diapositivas que hay. La app es su dueña: los cambios se informan con onChange, y un nuevo index se desplaza hasta allí, con animación. |
defaultIndex |
.default_index(..) |
number |
0 |
La diapositiva mostrada al principio, mientras index no está definido. |
axis |
.axis(..) |
"horizontal" | "vertical" |
"horizontal" |
La dirección en que se suceden las diapositivas; se lee al crear el carrusel. Las teclas de flecha de un carrusel vertical son Arriba y Abajo. |
looping |
.looping(..) |
boolean |
false |
Las diapositivas dan la vuelta: tras la última viene la primera, y antes de la primera la última, sin saltos, tanto para un deslizamiento, anterior, siguiente y las teclas como para el index de la app (Bucle). Se lee al crear el carrusel. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Cómo llaman los lectores de pantalla al carrusel, una región: qué son sus diapositivas ("Featured products"). |
children |
.child(..) |
JSX.Element |
ninguno | El CarouselContent, los controles y lo que la app disponga a su alrededor. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El layout del carrusel: dónde van el contenido y los controles. |
#CarouselContent
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
children |
.child(..) |
JSX.Element |
ninguno | Los CarouselItem, cada uno hijo del contenido. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El tamaño del viewport y la pista que forman en él las diapositivas: un gap entre ellas, padding que deja ver un adelanto de las diapositivas contiguas a la que está en vista. |
#CarouselItem
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Cómo llaman los lectores de pantalla a la diapositiva, un grupo: su lugar entre las demás y lo que muestra ("2 of 5: Ocean view"). |
children |
.child(..) |
JSX.Element |
ninguno | El contenido de la diapositiva. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | Su tamaño a lo largo del eje (w-full para una diapositiva por vista, basis-1/3 para tres) y su aspecto. En un carrusel con bucle, su traslación pertenece al carrusel: coloca un transform en una vista dentro de ella. |
#CarouselPrevious, CarouselNext
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | El nombre del control, cuando sus hijos no tienen texto que lo diga (un icono de flecha). |
children |
.child(..) |
JSX.Element |
ninguno | El icono o la etiqueta de la app. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | Su aspecto y su posición: junto al contenido, o absolute sobre su borde. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
ninguno | Su estilo mientras está pulsado, con el cursor encima, con el foco o deshabilitado en un extremo, como las clases <state>:. |
#CarouselPagination
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Cómo llaman los lectores de pantalla al grupo de elementos de paginación ("Choose a slide"). |
indicatorClass |
.indicator_class(..) |
string |
ninguno | Las clases de Tailwind del indicador, una vista sobre el elemento de la diapositiva mostrada (una píldora detrás de los puntos, un subrayado): su aspecto y su posición a lo ancho de la fila (inset-y-0, bottom-0 h-0.5). A lo largo de la fila abarca ese elemento y salta al siguiente de forma nativa. |
indicatorStyle |
.indicator_style(|s| ..) |
StyleProp<ViewStyle> |
ninguno | El estilo del indicador, sobre indicatorClass. Sin ninguno de los dos, la paginación no tiene indicador. |
children |
.child(..) |
JSX.Element |
ninguno | Los CarouselPaginationItem, y lo que la app disponga entre ellos. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El layout de la fila (flex-row gap-2). |
#CarouselPaginationItem
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
index |
.index(..) |
number |
obligatorio | La diapositiva que representa, desde 0. Un índice posterior a la última diapositiva queda deshabilitado. |
selectedStyle |
.selected_style(|s| ..) |
StyleProp<ViewStyle> |
ninguno | Su estilo mientras se muestra su diapositiva, como clases selected:. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Qué diapositiva representa ("Slide 3"), cuando sus hijos no tienen texto que lo diga (un punto). |
children |
.child(..) |
JSX.Element |
ninguno | El punto, la miniatura o el número de la app. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | Su aspecto; las clases group-selected: dentro de él lo siguen con group. |
#Eventos
| Evento | Rust | Descripción |
|---|---|---|
onChange (Carousel) |
.on_change(|index: &u32| ..) |
El usuario pasó a otra diapositiva: un deslizamiento, la rueda o el touchpad se detuvo en ella, o un control, un elemento de paginación o una tecla la pidió. { index } es esa diapositiva. Un nuevo index de la app no informa de nada. |
onDragStart (Carousel) |
.on_drag_start(|| ..) |
El usuario empezó a arrastrar el contenido (un dedo, el touchpad). |
onDragEnd (Carousel) |
.on_drag_end(|| ..) |
El contenido que el usuario arrastró se detuvo. |
onFocus (Carousel) |
.on_focus(|| ..) |
El foco del teclado entró en el carrusel: en su propia vista, en su control anterior o siguiente, en un elemento de paginación o en una vista enfocable de una diapositiva. Moverse entre ellos no informa de nada. |
onBlur (Carousel) |
.on_blur(|| ..) |
El foco del teclado salió del carrusel: de su propia vista, sus controles, sus elementos de paginación y el contenido de las diapositivas. |
#Partes
| Parte | Dentro de | Estilizada con | Estados |
|---|---|---|---|
| El carrusel | class, style de Carousel |
focus: |
|
| El contenido | el carrusel | class, style de CarouselContent |
|
| Una diapositiva | el contenido | class, style de CarouselItem |
|
| Anterior, siguiente | el carrusel | sus class, style |
disabled:, active:, hover:, focus: |
| La paginación | el carrusel | class, style de CarouselPagination |
|
| El indicador | la paginación | indicatorClass, indicatorStyle |
|
| Un elemento de paginación | la paginación | sus class, style |
selected:, disabled:, active:, hover:, focus: |
#Varias diapositivas por vista
El ancho de una diapositiva lo da su propia clase, así que una fila de tarjetas son varias diapositivas en el viewport. Cada diapositiva es un punto de ajuste donde empieza: el contenido se detiene con esa diapositiva allí donde se detiene la primera, de modo que un padding antes de la primera se conserva antes de cada una. Al final del rango, las diapositivas que no pueden llegar a ese lugar se detienen con la última, y siguiente se detiene ahí. index nombra la diapositiva que está en el primer lugar; las diapositivas que se detienen con la última muestran el mismo lugar, y un deslizamiento allí informa de la primera de ellas:
<Carousel accessibilityLabel="Collections">
<CarouselContent class="w-full gap-4 px-4">
<Each of={collections()}>
{(c) => (
<CarouselItem class="basis-1/3">
<Card collection={c()} />
</CarouselItem>
)}
</Each>
</CarouselContent>
</Carousel>
basis-1/2 con el ancho de un teléfono, o basis-[85%], muestra un adelanto de la tarjeta siguiente. Un carrusel vertical (axis="vertical") dimensiona sus diapositivas por su alto.
#Bucle
Con looping, las diapositivas dan la vuelta sin saltos: tras la última viene la primera, ya sea deslizando, avanzando con siguiente o con las teclas, o mediante el index de la app, sin rebobinar a través de las diapositivas intermedias. El contenido guarda espacio para una vuelta completa de diapositivas antes y después de ellas, y dibuja cada diapositiva donde está más cerca del centro de la vista; la animación ejecuta esto en los fotogramas propios de la plataforma, junto con el scroll. Cada diapositiva existe una sola vez: los lectores de pantalla y la paginación la ven una vez.
El bucle es continuo mientras las diapositivas abarquen el viewport y una diapositiva más (tres diapositivas de ancho completo, o cinco de un tercio de ancho). Con menos, anterior, siguiente y las teclas siguen dando la vuelta, desplazándose hacia atrás a través de las diapositivas.
#Diapositivas que avanzan solas
Un banner que avanza solo es cosa de la app: un temporizador que establece index. El carrusel informa de cuándo el usuario está interactuando con él, de modo que el temporizador espera en ese momento, como pide el patrón de carrusel de WAI-ARIA: onDragStart / onDragEnd alrededor de un arrastre, onFocus / onBlur mientras el foco del teclado está en el carrusel (en él, en sus controles, en su paginación o en el contenido de las diapositivas), y el puntero sobre una vista que lo rodee. Déjalo desactivado con movimiento reducido, y da a los usuarios un botón que lo detenga (el control de rotación):
import { cleanup, effect, reduceMotion, signal, Text, View } from "nsh";
import { Button, Carousel, CarouselContent } from "nsh/base";
const index = signal(0);
const playing = signal(true);
const held = signal(0);
const hold = (on: boolean) => held.set(held() + (on ? 1 : -1));
effect(() => {
index();
if (!playing() || held() > 0 || reduceMotion()) return;
const timer = setInterval(() => index.set((index() + 1) % SLIDES.length), 4000);
cleanup(() => clearInterval(timer));
});
<View onMouseEnter={() => hold(true)} onMouseLeave={() => hold(false)}>
<Carousel
looping
index={index()}
onChange={({ index: next }) => index.set(next)}
onDragStart={() => hold(true)}
onDragEnd={() => hold(false)}
onFocus={() => hold(true)}
onBlur={() => hold(false)}
accessibilityLabel="Highlights"
>
<CarouselContent class="w-full">{/* the slides */}</CarouselContent>
<Button onPress={() => playing.set(!playing())} class="self-start px-3 py-1">
<Text>{playing() ? "Pause slides" : "Play slides"}</Text>
</Button>
</Carousel>
</View>
Leer index() dentro del efecto inicia un intervalo completo de nuevo tras cada cambio, incluido el del usuario. Con looping, pasar de la última diapositiva a la primera avanza hacia delante a través de la unión.
#Accesibilidad
- El carrusel es una región con el nombre dado por
accessibilityLabel; cada diapositiva es un grupo con su propio nombre (su lugar entre las demás y lo que muestra). - Anterior y siguiente son botones, deshabilitados en los extremos salvo que el carrusel haga bucle; cada elemento de paginación es un botón, seleccionado mientras se muestra su diapositiva.
- Cada etiqueta es de la app, en el idioma de la app.
- Las diapositivas de un carrusel con bucle existen una sola vez cada una, estén donde estén dibujadas.
Consulta Accesibilidad para ver etiquetas, roles y anuncios en general.
#Teclado
En macOS y en la web, donde Tab llega a las vistas que reciben pulsaciones, el carrusel es una parada del foco del teclado, y también lo son sus controles y sus elementos de paginación. Mientras uno de ellos tiene el foco:
| Tecla | Qué hace |
|---|---|
| Izquierda, Derecha (Arriba, Abajo en vertical) | Pasa a la diapositiva anterior o a la siguiente, dando la vuelta por los extremos con bucle. |
| Inicio, Fin | Pasa a la primera o a la última diapositiva. |
| Espacio, Retorno | Pulsa el control o el elemento de paginación enfocado. |
En un layout de derecha a izquierda, las diapositivas de un carrusel horizontal van desde la derecha, de modo que Izquierda pasa a la diapositiva siguiente y Derecha a la anterior; un carrusel vertical conserva Arriba y Abajo. Un control que se deshabilita mientras tiene el foco (siguiente en la última diapositiva) le pasa el foco al carrusel, para que las teclas sigan funcionando allí. Las vistas enfocables de la app dentro de las diapositivas conservan sus propias teclas: las flechas de un input mueven su cursor de texto.
#Movimiento
- Un deslizamiento, la rueda y el touchpad mueven las diapositivas con el puntero, en el mismo fotograma: las desplaza la plataforma.
- Al soltar, el contenido se desliza hasta la diapositiva más cercana, una diapositiva por gesto de lanzamiento, tal como lo ajusta la plataforma.
- Anterior, siguiente, un elemento de paginación, una tecla y el
indexde la app desplazan el contenido hasta la diapositiva, con animación; con movimiento reducido, de inmediato. - El indicador de la paginación salta al elemento de la diapositiva mostrada; con movimiento reducido se mueve de inmediato.
loopingpasa de la última diapositiva a la primera avanzando un paso, a través de la unión; cuando el contenido se detiene, retrocede una vuelta completa sin animación, lo que no cambia nada en pantalla.
#Notas por plataforma
- iOS, Android y Mac Catalyst: un dedo desliza las diapositivas; un toque pulsa un control.
- Escritorio (macOS, Windows, Linux, GPUI) y la web: la rueda y el touchpad desplazan las diapositivas, y un clic pulsa un control. Un arrastre con el ratón sobre el contenido queda en manos de la app, como en toda scroll view allí.
- Todas las plataformas dibujan lo que la app estiliza: las diapositivas, los controles, la paginación y su indicador.