Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Popover

Un popover muestra contenido junto a su disparador, por encima de todo lo demás: detalles, un formulario pequeño, un selector de color. Pulsar el disparador lo abre y lo cierra; una pulsación fuera, Escape, el botón Atrás de Android y el gesto de escape de VoiceOver lo cierran. Se coloca en un lado del disparador, pasa al otro lado cuando no hay espacio, permanece dentro del área segura y sigue al disparador cuando este se desplaza. No dibuja nada propio: el popup y su contenido pertenecen a la app.

el-popover.tsx
el-popover.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Popover } from "nsh/base";
import { button, buttonText, desktop, Inspector, Log, PopIn, Segmented, Stage, ui, wide } from "./palette";

type Placement = "top" | "bottom" | "left" | "right";

function App() {
  const placement = signal<Placement>("bottom");
  const offset = signal(8);
  const events = signal<string[]>([]);
  const presses = signal(0);
  const placements = () => (
    <Segmented label="placement" options={["top", "bottom", "left", "right"] as Placement[]} value={placement()} onChange={(v) => placement.set(v)} />
  );
  const offsets = () => <Segmented label="offset" options={[0, 8, 16]} value={offset()} onChange={(v) => offset.set(v)} />;
  const popover = () => (
    <Popover
      placement={placement()}
      offset={offset()}
      margin={12}
      onOpenChange={({ open, reason }) => events.set([`${open ? "opened" : "closed"} · ${reason}`, ...events()].slice(0, 4))}
      trigger={
        <View {...button("accent")}>
          <Text style={buttonText("accent")}>Order #1024</Text>
        </View>
      }
    >
      {/* The panel grows out of the trigger's side each time it opens. */}
      <PopIn side={placement()} class="w-56 gap-2 rounded-xl p-4" style={ui().card}>
        <Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>Shipped yesterday</Text>
        <Text style={{ fontSize: 13, color: ui().muted }}>Arriving Friday. The trigger, a press outside and Escape close this.</Text>
        <View style={{ flexDirection: "row", gap: 6, alignItems: "center" }}>
          <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: ui().up }} />
          <Text style={{ fontSize: 12, color: ui().up }}>In transit</Text>
        </View>
      </PopIn>
    </Popover>
  );
  const count = () => (
    <View
      onPress={() => presses.update((n) => n + 1)}
      {...button("secondary", { align: "center" })}
    >
      <Text style={buttonText()}>Count · pressed {presses()}×</Text>
    </View>
  );
  const log = () => <Log title="onOpenChange({ open, reason })" lines={events()} minHeight={92} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>popover</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Open it, then click “Count” below: one click closes it and presses the button"
          : "Open it, then tap “Count” below: the tap only closes it"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage gap={24}>
              <View style={{ height: 280, alignItems: "center", justifyContent: "center" }}>{popover()}</View>
              {count()}
            </Stage>
            <Inspector>
              {placements()}
              {offsets()}
              {log()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flexDirection: "row", gap: 12 }}>
              <View style={{ flex: 3 }}>{placements()}</View>
              <View style={{ flex: 2 }}>{offsets()}</View>
            </View>
            <View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>{popover()}</View>
            {count()}
            {log()}
          </>
        )
      }
    </View>
  );
}

mount(App);
use nsh_base::{OpenChangeReason, PopoverEvent, popover};
use nsh_core::prelude::*;

fn details() -> impl IntoElement {
    popover()
        .offset(px(6.0))
        .margin(px(8.0))
        .trigger(view().px_3().py_2().rounded_md().bg(rgb(0xe5e7eb)).child(text("Details")))
        .on_open_change(|e: &PopoverEvent| {
            if let PopoverEvent::OpenChange(open, OpenChangeReason::PressOutside) = e {
                log::info!("closed by a press outside: {open}");
            }
        })
        .child(
            view()
                .w(px(240.0))
                .gap_1()
                .p_3()
                .rounded_lg()
                .bg(white())
                .shadow_lg()
                .child(text("Order #1024").font_weight(FontWeight::SEMIBOLD))
                .child(text("Shipped yesterday, arriving Friday.")),
        )
}

Popover proviene de nsh/base. Su contenido se crea cada vez que se abre, por lo que el estado interno empieza de cero.

#Estado de apertura

Sin open, el popover mantiene su propio estado, a partir de defaultOpen. Con open, el estado pertenece a la app: cámbialo en onOpenChange({ open, reason }), que informa de cada solicitud de apertura o cierre junto con su motivo ("triggerPress", "pressOutside", "cancel").

const open = signal(false);

<Popover open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>Filters</Text>}>
  <FilterForm onApply={() => open.set(false)} />
</Popover>

closeOnEscape={false} lo mantiene abierto con Escape y Atrás (onCancel sigue recibiéndolos, para preguntar antes); closeOnPressOutside={false} lo mantiene abierto ante pulsaciones fuera.

#Teléfonos y escritorios

Un popover está anclado a su disparador en todos los dispositivos: lo que muestra pertenece junto a lo que lo abrió.

  • En los escritorios y en los navegadores de escritorio, una pulsación fuera lo cierra y continúa hacia lo que hay bajo el puntero, de modo que un solo clic cierra el popover y además pulsa el botón sobre el que cayó.
  • En teléfonos y tabletas, una pulsación fuera solo lo cierra: un fondo transparente captura la pulsación, como hacen los popovers del sistema.

Para una lista de acciones usa un Menu, que se convierte en una hoja en los teléfonos; para un panel usa un Sheet.

#Entrada y salida

Un popover aparece y desaparece con tus transiciones. popupStartingClass es el aspecto del popup en su primer fotograma, y las clases transition-* de popupClass lo llevan a su aspecto propio: su entrada. popupEndingClass es su aspecto una vez cerrado: el popover permanece, sin recibir pulsaciones, mientras se ejecutan esas transiciones, y luego desaparece, y onClose lo sigue. Si se abre de nuevo mientras sale, vuelve a su aspecto desde donde se encuentra. popupStartingStyle y popupEndingStyle hacen lo mismo con estilos en línea. Cuando el usuario pide menos movimiento, aparece y desaparece de inmediato.

<Popover
  popupClass="rounded-lg bg-white p-3 shadow-lg transition-all duration-150 ease-out"
  popupStartingClass="opacity-0 scale-95"
  popupEndingClass="opacity-0 scale-95"
  onPosition={({ placement }) => side.set(placement)}
  trigger={<Text>Details</Text>}
>
  <OrderSummary />
</Popover>

onPosition({ placement, bounds, available }) indica dónde quedó el popup: el lado del disparador (el otro cuando placement no tenía espacio), su marco y el espacio disponible en ese lado, de modo que una flecha o una entrada puedan apuntar al disparador.

#Props

Prop Rust Tipo Valor por defecto Descripción
open .open(..) boolean ninguno Abierto, controlado por la app.
defaultOpen .default_open(..) boolean false Abierto al inicio, sin open.
placement .placement(..) "top" | "bottom" | "left" | "right" "bottom" El lado del disparador en el que se coloca.
align .align(..) "start" | "center" | "end" "center" Cómo se alinea con el disparador a lo largo de ese lado.
offset .offset(..) number 0 El espacio entre el disparador y el popup.
margin .margin(..) number 0 La distancia que mantiene respecto a los bordes del área segura.
closeOnEscape .close_on_escape(..) boolean true Escape y el botón Atrás de Android lo cierran.
closeOnPressOutside .close_on_press_outside(..) boolean true Una pulsación fuera lo cierra.
popupClass, popupStyle .popup_class(..), .popup_style(..) string, StyleProp<ViewStyle> ninguno El aspecto del popup, la caja en la que está el contenido.
popupStartingClass, popupStartingStyle .popup_starting_class(..), .popup_starting_style(..) string, StyleProp<ViewStyle> ninguno El aspecto del popup en su primer fotograma, sobre el propio: sus transiciones parten de él.
popupEndingClass, popupEndingStyle .popup_ending_class(..), .popup_ending_style(..) string, StyleProp<ViewStyle> ninguno El aspecto del popup una vez cerrado, sobre el propio: permanece mientras se ejecutan sus transiciones.
trigger .trigger(..) JSX.Element ninguno Lo que lo abre; el popup se coloca respecto a él. Mientras el popover está abierto se aplican sus clases aria-expanded: y expandedStyle.
children .child(..) JSX.Element ninguno El contenido.

#Eventos

Evento Rust Descripción
onOpenChange .on_open_change(|e| ..) Se abrió o se cerró, con el motivo: "triggerPress", "pressOutside" o "cancel".
onOpen .on_open(|| ..) Se muestra.
onClose .on_close(|| ..) Ya no está, tras su salida; también cuando la app lo cerró.
onCancel .on_cancel(|| ..) El usuario intentó cerrarlo (Escape o Atrás, una pulsación fuera), se haya cerrado o no.
onPosition .on_position(|e| ..) El popup fue colocado: placement (el lado en el que quedó), bounds (su marco), available (el espacio disponible en ese lado).

#Accesibilidad

El disparador indica a los lectores de pantalla si el popover está expandido. Escape y el botón Atrás lo cierran, como los usuarios esperan de un popup. En TSX el disparador es el elemento que pasas, un Button o una vista propia: recibe las pulsaciones y el estado expandido por sí mismo, por lo que sus propias clases active: y activeStyle se muestran mientras está pulsado, y su propio onPress también se ejecuta.