Dialog

Dialog é um modal controlado com portal, retenção de foco, bloqueio de rolagem e caminhos de fechamento acessíveis.

Exemplos

Confirmando uma ação destrutiva

O rodapé é onde as ações moram — o caminho de cancelar primeiro, o destrutivo por último.

Bloqueando o fechamento acidental

Desligue closeOnEsc e closeOnOverlayClick só quando perder o diálogo significar perder trabalho ou pular uma decisão. Nunca remova todos os caminhos de fechamento: o × aparece sempre que onClose está definido.

Quando usar

Use um Dialog para uma decisão curta e focada que precisa ser resolvida antes de o usuário seguir em frente: confirmar uma exclusão, nomear um recurso novo, revisar termos.

Prefira outro componente quando:

  • O conteúdo é longo ou a tarefa é uma jornada paralela — use Drawer, que entra deslizando sem tomar a tela inteira.
  • Você só precisa relatar o resultado de algo — use Toast (transitório) ou Alert (persistente, inline).
  • O painel é ancorado a um controle e contém opções — use Dropdown.

Acessibilidade

  • O painel é role="dialog" com aria-modal, nomeado pelo title via aria-labelledby.
  • Focus moves to a safe initial destination in the panel on open and is trapped while open. For React, declare initialFocusTo when the application owns the task meaning, and returnFocusTo when a pointer-opened workflow needs an explicit return destination.
  • Todo caminho de fechamento chama onClose: o botão ×, o Escape e o clique no backdrop. O × aparece só quando onClose é fornecido, então um diálogo nunca fica sem uma saída visível.
  • O botão × tem o nome "Close" por padrão. Passe closeLabel para traduzir seu nome acessível.
  • A rolagem da página fica bloqueada enquanto o diálogo está aberto, e o painel é portado para o document.body, escapando de qualquer ancestral com overflow: hidden.

React initial focus

initialFocusTo is optional and has the type () => HTMLElement | null. For each accepted opening of a visible modal panel, Lyra synchronously calls the read-only resolver once. Return a current, eligible member of that panel; the application chooses the meaning: the least-destructive Cancel action for a confirmation, a named reading heading for long content, or the intended field for an ordinary form. The DeleteProject snippet deliberately enters on Cancel; both footer actions close the demo dialog.

An eligible target is an HTMLElement in the panel's document and current modal panel, connected, visible with rendered rectangles, enabled (including its fieldset), programmatically focusable, and outside hidden, inert, or aria-hidden ancestry. A named heading or reading container with tabIndex={-1} is valid. A null or invalid result focuses the named panel directly; it never falls through to another control. Omitting the option uses the first eligible task control, then the panel. The resolver is not replayed on rerenders; a later accepted opening reads the current resolver.

Form composition owns focus after failed validation. Initial entry does not infer a validation failure from aria-invalid. The resolver must only read committed state and refs: do not focus, mutate, or start asynchronous work. If it throws, Lyra focuses the panel and propagates the same error through React's effect handling.

React return focus

returnFocusTo is optional and has the type () => HTMLElement | null. It resolves freshly after an accepted close, so it can read the current workflow state and refs instead of preserving a stale element. Use it for a stable, eligible trigger; when that trigger can be removed, disabled, hidden, or no longer meaningful, return a logical successor instead.

An eligible destination is connected in the same document, visible, enabled, programmatically focusable, and outside the closing panel and overlay. A composition must not return a target that it removes while closing. Omitting returnFocusTo retains only a valid captured opener that was already focused, such as after keyboard opening. Unprepared WebKit mouse usage without a usable explicit target remains unqualified: adding this optional prop does not repair unmigrated consumers.

tsx
function DeleteProject({ canReturnToTrigger }: { canReturnToTrigger: boolean }) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const successorRef = useRef<HTMLHeadingElement>(null);
  const cancelRef = useRef<HTMLButtonElement>(null);

  return (
    <>
      <Button ref={triggerRef} disabled={!canReturnToTrigger} onClick={() => setOpen(true)}>
        Delete project
      </Button>
      <h2 ref={successorRef} tabIndex={-1}>
        Project overview
      </h2>
      <Dialog
        open={open}
        onClose={() => setOpen(false)}
        initialFocusTo={() => cancelRef.current}
        returnFocusTo={() =>
          canReturnToTrigger ? (triggerRef.current ?? successorRef.current) : successorRef.current
        }
        title="Delete project"
        footer={
          <>
            <Button ref={cancelRef} variant="ghost" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button variant="danger" onClick={() => setOpen(false)}>
              Delete
            </Button>
          </>
        }
      >
        This action cannot be undone.
      </Dialog>
    </>
  );
}

Project overview is a named, meaningful tabIndex={-1} destination. The canReturnToTrigger state represents current availability; a non-null ref alone does not make a target eligible. A removed trigger also falls through its null ref to the successor.

After an accepted close, Lyra prefers an eligible resolver result, then only a still-eligible captured opener. It never picks an arbitrary page control or body as a fallback. If neither is eligible, Lyra makes no invalid focus call and the controlled close still proceeds; the browser may land on body. This is an invalid composition, and the application must provide a logical successor. One development diagnostic reports the invalid composition for that close cycle; it is not successful focus restoration. The resolver must only read current state and refs: do not focus, mutate the DOM, or start asynchronous work in it.

API e código

NomeTipoObrigatóriaDescrição
openbooleanObrigatóriaControls visibility. `true` mounts the overlay + panel; `false` plays the exit then unmounts.
onClose() => void—Called by every close path (Esc, overlay click, the × button). The × renders only when this is provided.
closeLabelstring—Accessible name for the close button. Default: `"Close"`.
titleReactNodeObrigatóriaHeading rendered in the panel header and wired as the accessible name via `aria-labelledby`.
footerReactNode—Action buttons rendered in the footer. Omitted → no `.lyra-dialog__footer` chrome.
closeOnEscboolean—Close on the Escape key. Default `true`.
closeOnOverlayClickboolean—Close on a click on the overlay backdrop (never on panel content). Default `true`.
containerHTMLElement—Portal host. Defaults to `document.body`.
returnFocusTo() => HTMLElement | null—Resolves the current logical destination for focus after an accepted close.
initialFocusTo() => HTMLElement | null—Resolves the initial focus destination inside the modal on each accepted opening.
childrenReactNodeObrigatóriaBody content.

x-data="lyraDialog({ … })"

OpçãoTipoObrigatóriaDescrição
defaultOpenboolean—
closeOnEscboolean—
closeOnOverlayClickboolean—
labelIdstring—
returnFocusTo() => HTMLElement | null—Resolves the current logical focus destination after an accepted modal close.

After the existing Alpine plugin installation (Alpine.plugin(lyra)), use the registered lyraDialog binding; do not import its internal factory. The plugin owns opening and closing, focus trapping, scroll locking, Escape, and the guarded backdrop click. It does not portal this consumer-served composition automatically.

returnFocusTo is initial binding configuration, not mutable callback data. On an accepted close it synchronously reads current state or refs once: Lyra prefers its eligible explicit target with focus({ preventScroll: true }), then an eligible captured opener with ordinary focus() and scroll behavior. The target must be connected, visible, enabled, focusable, in the same document, outside the closing overlay and panel, and outside hidden, inert, or aria-hidden content. There is no arbitrary body fallback. Return a meaningful successor when the trigger is removed or becomes unsafe; do not focus, mutate, or start asynchronous work from the resolver. This example treats an absent, disabled, or hidden trigger as unavailable and returns the named successor. Other application availability changes, including hidden or inert ancestry and a no-longer-meaningful trigger, must choose their meaningful successor from current state; this example covers its named simple states, not arbitrary automatic discovery. Initial closed state, opening, ignored close requests, exit, and destroy do not resolve it.

html
<div
  x-data="lyraDialog({
    returnFocusTo: () => {
      const trigger = document.getElementById('open-project-details');
      return trigger instanceof HTMLButtonElement && !trigger.disabled && !trigger.hidden
        ? trigger
        : document.getElementById('project-overview');
    },
  })"
>
  <button
    id="open-project-details"
    class="lyra-btn lyra-btn--primary lyra-btn--md"
    type="button"
    @click="open = true"
  >
    Open project details
  </button>
  <h2 id="project-overview" tabindex="-1">Project overview</h2>

  <div class="lyra-dialog-overlay" x-bind="overlay" style="display: none">
    <div class="lyra-dialog" x-bind="panel">
      <div class="lyra-dialog__header">
        <h2 class="lyra-dialog__title" x-bind="title">Project details</h2>
        <button class="lyra-dialog__close" x-bind="close">✕</button>
      </div>
      <div class="lyra-dialog__body">Review the latest deployment before you continue.</div>
      <div class="lyra-dialog__footer">
        <button class="lyra-btn lyra-btn--primary lyra-btn--md" type="button" x-bind="close">
          Close
        </button>
      </div>
    </div>
  </div>
</div>

<lyra:dialog> Gerado do lyra-ds/blade v0.10.0.

O comportamento vem de lyraDialog() — instale @lyra-ds/alpine e veja a aba HTML + Alpine.

PropPadrãoObrigatóriaValores de exemplo
title—Obrigatória—
closabletrue——
closeLabel'Close'——
defaultOpenfalse——
closeOnEsctrue——
closeOnOverlayClicktrue——
labelIdnull——
blade
<lyra:dialog title="Delete project" close-label="Close">
    This permanently deletes the project and every issue attached to it.
</lyra:dialog>