Saltar al contenido
zabloo

Overlay

Contenido que pinta por encima del resto de la pantalla: diálogos, toasts, tooltips, desplegables. Lo declaras donde vive la UI que lo abre, y nunca empuja a sus hermanos.

primitivedesde v1sin focus

Un Overlay es contenido que pinta por encima de todo lo demás de la pantalla: un diálogo de confirmación, un toast, un tooltip, la lista abierta de un desplegable. Lo escribes dentro de la UI que lo abre, pero se levanta a una capa propia, así que nada de alrededor se mueve para hacerle sitio. Piensa en un menú de pausa con un diálogo de Quit?: el diálogo se declara dentro del panel del menú, un booleano del que es dueño el juego decide si está ahí, y mientras lo esté no se puede pulsar nada de debajo.

overlay-modal.viewIR v1
Viewport

Viewport: 960 × 380

A Main menu panel with Continue and Quit buttons, dimmed under a dark backdrop, and over it a centred dialog asking Quit the game? with a filled Quit button beside a Cancel one.
ESTADO
  • hover — inactivo
  • pressed — inactivo
  • focused — inactivo
  • selected — inactivo
  • disabled — inactivo

Se lee del último frame dibujado

Parado: pulsa Ejecutar para dibujarlo en la GPU.

import { Button, Column, Modal, Row, Text } from "@zabloo/react";

export default function OverlayModal() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it instead would pin the panel to its content and no
      // change of viewport could reach it (ZAB-153).
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        layout={{ padding: "{space.5}", gap: "{space.3}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>Main menu</Text>
        <Text style={{ color: "{color.faint}", fontSize: "{text.sm}" }}>
          Everything under the backdrop is still there — it is unreachable, not gone.
        </Text>
        <Row layout={{ gap: "{space.2}" }}>
          <Button
            id="continue"
            variant="secondary"
            onClick="continue"
            layout={{ grow: 1, height: 38, justify: "center", align: "center" }}
          >
            <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Continue</Text>
          </Button>
          <Button
            id="quit"
            variant="secondary"
            onClick="quit-ask"
            layout={{ width: 96, height: 38, justify: "center", align: "center" }}
          >
            <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Quit</Text>
          </Button>
        </Row>

        {/* The component IS the backdrop — its own style paints it — and `panel`
            styles the card inside. That is why there is no `backdrop` prop. */}
        <Modal
          id="confirm-quit"
          visible={{ bind: "ui.confirmQuit" }}
          onDismiss="quit-cancelled"
          transition={{ duration: "{motion.fast}" }}
          style={{ background: "#000000a6" }}
          layout={{ padding: "{space.6}" }}
          panel={{
            layout: { width: 300, padding: "{space.5}", gap: "{space.4}", align: "stretch" },
            style: {
              background: "{color.raised}",
              radius: "{radius.lg}",
              borderWidth: "{border.hairline}",
              borderColor: "{color.line-strong}",
            },
          }}
        >
          <Text style={{ color: "{color.text}", fontSize: "{text.md}" }}>Quit the game?</Text>
          <Row layout={{ gap: "{space.2}" }}>
            {/* `autofocus` takes the focus when the modal opens; the SDK gives
                it back to whatever held it when the modal closes. */}
            <Button
              variant="primary"
              onClick="quit-confirm"
              autofocus
              layout={{ grow: 1, height: 36, justify: "center", align: "center" }}
            >
              <Text style={{ color: "{color.on-brand}", fontSize: "{text.sm}" }}>Quit</Text>
            </Button>
            <Button
              variant="secondary"
              onClick="quit-cancelled"
              layout={{ grow: 1, height: 36, justify: "center", align: "center" }}
            >
              <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Cancel</Text>
            </Button>
          </Row>
        </Modal>
      </Column>
    </Column>
  );
}
El diálogo está declarado dentro del panel que tapa — mira el panel de detrás y fíjate en que no se ha desplazado nada para hacerle hueco. Pulsa Run y luego Escape: el SDK escribe false de vuelta por el mismo binding que lo abrió, y por eso cerrar no necesita ningún mecanismo propio.

El SDK reúne todos los Overlay visibles de la vista en una sola capa pintada por encima del árbol entero, ordenada por (z, orden del documento).

Las dos tablas de abajo apenas se solapan, y position es la razón. Lo que escribes es uno de nueve nombres; lo que se publica es el layout.justify/align/padding en que esos nombres se resuelven — porque la colocación de un overlay siempre fue un layout sobre un rect del tamaño de la vista.

Props de autoría

<Overlay> emite este nodo y nada más: no tiene slots posicionales, así que no hay ninguna convención de la que un componente tenga que ser dueño. Los tres composites de abajo son las formas ya hechas.

PropTipoPor defectoDescripción
positionuna de las nueve colocacionessegún el componenteColocación en la capa, o alrededor del anchor cuando lo hay.
anchorstringabsentid del nodo del que colgar.
offsetDim8Distancia al borde del anchor. Sin anchor se ignora.
trigger"manual" | "hover" | "press"según el componenteLo que lo mete en la capa.
autoCloseMsnumbersegún el componenteRetardo hasta que se cierra solo.
onDismissstringabsentNamed action que se dispara ante una petición de cierre.
panelContainerPropsabsentLa tarjeta, píldora o bocadillo donde va el contenido — las props enteras de un Container.

Además de estas, todo componente acepta las props base del nodo —id, visible, disabled, layout, style, states, transition, autofocus, clip— más variant, que el theme resuelve al exportar y que nunca aparece en la IR.

Props de IR

Lo que llega al juego, cuando ya no queda capa de autoría.

PropTipoPor defectoDescripción
modalbooleantrueBloquea el input de debajo y confina el focus a este subárbol.
znumber0Apilado explícito dentro de la capa; los empates los rompe el orden del documento.
onDismissstringabsentNamed action que se dispara ante una petición de cierre.
autoCloseMsnumberabsentMilisegundos hasta que el overlay pide su propio cierre.
anchorOverlayAnchorabsentColoca el contenido contra el rect de otro nodo: id, at, offset, trigger.
childrenZNode[][]El contenido.

La capa

El rect del propio overlay ES el rect de la vista. Por tanto:

  • layout.justify / align / padding colocan el contenido: un modal centrado, un toast abajo a la derecha, un margen que lo mantiene lejos de los bordes de la pantalla.
  • El layout.width / height del propio overlay se ignoran: una capa no se dimensiona. Dimensiona al hijo.
  • style.background es el backdrop. Un color translúcido oscurece lo que tapa; sin fondo ninguno, la capa es transparente. El pintado sigue siendo implícito, sin ningún campo de más.

Abrir y cerrar

visible es el único mecanismo

Exactamente igual que en todo el resto del formato. Un overlay escondido no aporta capa, ni backdrop, ni bloqueo de input — así que un juego abre un diálogo moviendo un booleano, y una petición de cierre (Escape, B del mando, un toque en el backdrop de un modal, o el autoCloseMs que se agota) es el SDK escribiendo false de vuelta por ese mismo binding y disparando onDismiss. Eso es lo que permite expresar el cierre sin un mecanismo propio: los datos del juego son la única fuente de verdad sobre qué está abierto.

autoCloseMs es un número pelado, no un Dim: es un tiempo de espera de comportamiento, no movimiento, y no hay nada en él tematizable como sí lo es la duración de una transition. Su reloj arranca cuando el overlay entra en la capa y se reinicia si sale y vuelve.

La modalidad

modal: true (el valor por defecto) hace dos cosas que en realidad son una sola afirmación —esto es lo único con lo que puedes interactuar ahora mismo—:

  • Captura del input. El hit-testing recorre primero la capa, de arriba abajo, y un overlay modal captura el punto: todo lo de debajo, overlays más bajos incluidos, queda fuera de alcance.
  • Trampa de focus. La navegación direccional se confina a este subárbol, y al cerrar el focus vuelve a quien lo tuviera antes. La trampa se deriva de modal — no hay un segundo campo.

modal: false —un toast, un tooltip— pinta por encima pero deja inerte el rect de la propia capa: solo sus hijos reciben eventos, y todo lo demás pasa de largo hacia el árbol de debajo.

El anclaje

Con un anchor, el contenido se coloca contra el rect de otro nodo en vez de contra la capa. Es la única pieza de layout de la v1 que es relativa a un rect que el nodo no contiene, y por eso es un campo aparte.

PropTipoPor defectoDescripción
idstringid del nodo de esta vista del que colgar.
atAnchorAt"top"Colocación preferida alrededor del anchor.
offsetDim8Distancia entre el borde del anchor y el contenido.
trigger"manual" | "hover" | "press""manual"Lo que lo mete en la capa.

El encaje es determinista, y no tiene campo propio. Si el contenido no cabe en el lado preferido y en el contrario hay sitio, se da la vuelta; después se acota dentro de la vista, conservando el layout.padding del propio overlay como margen respecto a los bordes. La colocación en la capa se emite siempre al lado de un anchor, que es exactamente lo que dibuja un SDK anterior al anclaje.

Un tooltip nunca señala a la nada. Si el anchor sale del layout —su visible se ha puesto a falso, su panel de pestaña se ha cerrado— o lo recorta del todo un ScrollView, el overlay sale también de la capa, con su fundido de salida. Un id que no resuelve a ningún nodo es un error de autoría y no estado de ejecución: el SDK avisa (unknown-anchor) y recae en la colocación de la capa, así que una errata degrada a un overlay visible en vez de al silencio.

Los triggers

  • manualvisible y nada más.
  • hover — se ve mientras el anchor tiene el puntero encima o el focus. Un solo valor para las dos cosas, porque son lo mismo a través de dispositivos distintos: en un mando, el focus es el hover, así que una pista llega a quien juega con mando sin un segundo mecanismo.
  • press — el popover, más abajo.

Un trigger hover o press necesita un anchor que acepte input: un Button, un Toggle, un Slider, la cabecera de un Collapse. El hover enciende exactamente el conjunto de lo focusable, que es también lo que hace que el puntero y el mando vean las mismas pistas.

Los popovers

trigger: "press" es el estado que ningún overlay tenía antes: visible podía abrir uno, pero nada de la IR podía cerrarlo en respuesta a algo que el jugador hiciera dentro — que es exactamente lo que es un desplegable. Así que el SDK es dueño de un flag de apertura por overlay anclado, indexado por la relación, igual que ya lo es de Collapse.open y de Toggle.checked.

  1. Pulsar el anchor lo abre y lo cierra. La pulsación que lo abre es la que lo cierra. El onClick del propio anchor se sigue disparando — abrirse es comportamiento, nunca un sustituto de la action declarada.
  2. Una petición de cierre lo cierra — el mismo camino del que cuelga onDismiss.
  3. Una selección dentro lo cierra. Cuando un grupo "exclusive-check" de dentro del popover toma un valor nuevo, el popover se cierra: elegir es el gesto que lo termina. Esto es lo que hace que <Select> se pueda expresar como composite en vez de como primitive.
  4. Al abrirse, el focus va a la selección — la opción marcada de ese grupo, para que la lista se abra donde el jugador la dejó; y si no la hay, al autofocus del subárbol. Al cerrarse, el focus vuelve al anchor.

Un SDK anterior a un valor de trigger lo lee como manual, así que el desplegable se queda abierto en la capa, donde lo haya puesto su anchor: una lista visible e inerte, en vez de un control que no aparece nunca.

Comportamiento

Estados

Solo disabled, y solo el suyo — un Overlay es donde se para la herencia, por ser lo más alto de su propio ámbito de input. Un modal declarado dentro de un panel deshabilitado sigue siendo operable y se puede cerrar, que es el comportamiento que necesita un diálogo de confirmación sobre una pantalla bloqueada.

Focusable

No, él no. Sus hijos conservan sus propios estados y su propio focus; lo que aporta el overlay es la trampa a su alrededor — la siguiente flecha que pulse el jugador no puede salir del diálogo.

Movimiento

Una transition funde su presencia en la capa: el SDK interpola la opacidad de la entrada entera según entra y sale, así que un overlay que se cierra se queda en pantalla exactamente una duración después de que visible se ponga a falso. visible en sí no se anima nunca — un overlay de salida es solo píxeles, y el input, la trampa de focus y los temporizadores leen la capa viva, de la que ya ha salido.

Actions

El jugador pulsa Escape, la B del mando, o toca el backdrop → el SDK escribe false por el binding que lo abrió → se dispara onDismiss junto a esa escritura. Una petición, una action, y el cierre ya ha ocurrido para cuando el juego se entera.

Degradación

En un SDK más antiguo

En un SDK más antiguo el diálogo se ve dentro del flujo, donde se escribió — en mitad del panel, sin backdrop oscurecido y sin nada bloqueado detrás.

Como Container, y este conviene saberlo: el contenido aterriza EN EL FLUJO en vez de en una capa. Se ve, en el sitio equivocado, sin backdrop y sin captura. Un diálogo degrada a una sección de la página, que se puede leer y alcanzar aunque no sea lo que dibujaste.

Quit?

Main menuQuit?

Cómo oye esto el juego

El binding dice si está abierto; la action dice que el jugador ha pedido cerrar. Llegan juntas y ninguna sustituye a la otra: el SDK ya ha escrito false para cuando onDismiss llega al juego, así que un handler no tiene que cerrar nada a mano — solo tiene que decidir qué significaba cerrar.

using UnityEngine;
using Zabloo;

[RequireComponent(typeof(ZablooDocument))]
public sealed class PauseMenu : MonoBehaviour
{
  ZablooDocument _doc;

  void Start()
  {
      _doc = GetComponent<ZablooDocument>();
      _doc.OnAction += OnZablooAction;
  }

  void OnDestroy()
  {
      if (_doc != null) _doc.OnAction -= OnZablooAction;
  }

  void OnZablooAction(string action)
  {
      // The game opens the dialog by moving the boolean it is bound to.
      if (action == "quit-ask") _doc.SetData("ui.confirmQuit", true);

      // Escape, gamepad B and the backdrop all land here — and the SDK has
      // already written false back, so there is nothing to close by hand.
      if (action == "quit-cancelled") ResumeGame();

      if (action == "quit-confirm") Application.Quit();
  }
}

Composición

Tres formas ya hechas, todas el mismo nodo con valores por defecto distintos. Los z por defecto son una convención de la capa de autoría, no una taxonomía del formato: un toast por encima de un modal, un tooltip por encima de los dos.

  • <Modal> — cuando el jugador tenga que responder antes de hacer nada más: una confirmación, una pantalla de subida de nivel, un error que hay que dar por enterado.
  • <Toast> — para una noticia que no necesita respuesta, ya que no es modal y se cierra solo: Game saved, Item sold.
  • <Tooltip> — para una pista pegada a un control, donde enseñarla mientras el jugador está en ese control es el trigger entero.
// Modal — position: "center", modal: true, z: 0. The component IS the
// backdrop (its style paints it), which is why there is no backdrop prop.
<Modal visible={{ bind: "ui.confirmQuit" }} onDismiss="quit-cancelled" transition={{ duration: 150 }}>
<Text>Quit the game?</Text>
<Row layout={{ gap: 8 }}>
  <Button onClick="quit-confirm" autofocus><Text>Quit</Text></Button>
  <Button onClick="quit-cancelled"><Text>Cancel</Text></Button>
</Row>
</Modal>

// Toast — position: "bottom", modal: false, autoCloseMs: 3000, z: 10.
// Non-modal, so the player keeps using what is underneath.
<Toast visible={{ bind: "ui.saved" }} onDismiss="toast-closed">Game saved</Toast>

// Tooltip — position: "top", modal: false, z: 20, and trigger: "hover"
// WHEN IT HAS AN ANCHOR: a hint about a control shows while you are on it.
<Button id="jump-btn" onClick="jump"><Text>Jump</Text></Button>
<Tooltip anchor="jump-btn" position="top">Press A to jump</Tooltip>

Relacionado