Skip to content

LayrzDialog

Kenny Mochizuki Escalona edited this page Aug 28, 2026 · 2 revisions

LayrzDialog

A Material-free modal dialog for presenting focused, page-relative interruptions — a centered, size-bounded panel behind a modal barrier.

Metadata Mirrors: None (new component for layrz_ui) Phase: M2 Core primitives Domain: Layout Primitive: RawDialogRoute (via LayrzModalRoute) Status: Shipped.


Overview

LayrzDialog is a static-method surface — there is no widget to construct, only LayrzDialog.show<T>(), which pushes a route and returns a Future<T?> that completes with whatever value the dialog's content passes to Navigator.pop, or null if the dialog is dismissed without one. This mirrors LayrzBottomSheet.show<T>() exactly, on purpose: LayrzBottomSheet already shipped in 0.0.14 and cannot change idiom without a breaking release, so the modal-surface family converges on its shipped form rather than introducing a second one.

A dialog is a page-relative interruption — centered, barriered, and distinct from both LayrzBottomSheet (which drops in from the bottom edge, sized to the viewport height) and an anchored overlay such as LayrzAnchoredPanel (which is field-relative, tethered to the widget that opened it, and carries no barrier at all). LayrzDialog never adapts into another surface based on viewport size — it stays a dialog at every viewport width. The viewport-driven choice between a dialog and a sheet is a separate, higher-level component: see LayrzResponsiveModal.

LayrzDialog is built on LayrzModalRoute, the same shared route base LayrzBottomSheet uses. That base was extracted from the sheet specifically so the barrier, reduce-motion handling, and — most importantly — the double-pop guard are shared by construction rather than reimplemented per surface. See Dismissal and the Double-Pop Guard below.


API Structure

LayrzDialog.show<T>()

class LayrzDialog {
  /// Shows a dialog and returns the result.
  static Future<T?> show<T>(
    BuildContext context, {
    /// Optional widget rendered in the dialog's title slot, above [content].
    Widget? title,

    /// Optional widget rendered in the dialog's body slot, below [title] and
    /// above [actions]. Wrapped in a scrollbar over a scroll view so content
    /// taller than the dialog's max height scrolls internally. **Receives
    /// unbounded height** from that scroll view — see "The `content` Slot's
    /// Unbounded Height" below.
    Widget? content,

    /// Optional list of widgets (typically LayrzButtons) rendered in a row at
    /// the bottom of the dialog, right-aligned with spacing between them.
    List<Widget>? actions,

    /// An escape hatch for content that does not fit the title/content/actions
    /// shape. Supplying this together with any of title/content/actions is
    /// not supported — an assertion enforces the choice at the call site.
    Widget? child,

    /// Whether the dialog can be dismissed by any route other than one of its
    /// own actions — barrier tap, Escape, the X close icon, and the system/
    /// Android back gesture. Defaults to `null`, which infers `true` when
    /// actions is null and `false` when actions is non-null.
    bool? canDismiss,

    /// Optional semantic label describing the dialog's purpose for screen
    /// readers, announced alongside the barrier label.
    String? semanticLabel,

    /// The maximum width the dialog's panel may occupy, in logical pixels.
    /// Defaults to `480`.
    double maxWidth = 480,

    /// The maximum height the dialog's panel may occupy, in logical pixels.
    /// Defaults to `640`.
    double maxHeight = 640,
  });
}

Return contract: Future<T?>, completing with the value passed to Navigator.pop inside the dialog, or null if dismissed without one (barrier tap, Escape, or a close action that pops with no value) — identical in shape to LayrzBottomSheet.show<T>().

Structure: slots plus an escape hatch

The known consumer set for this component — 15 M4 pickers plus LayrzSelectInput/LayrzMultiSelectInput/LayrzDualListInput — is overwhelmingly "title + body + confirm/cancel". LayrzDialog bakes that shape in as three named slots (title, content, actions) rather than asking every consumer to re-solve the same spacing and alignment independently.

For the outlier that fits none of them, child replaces the entire body. child and the slots are mutually exclusive: passing child together with any of title, content, or actions fires an assertion —

assert(
  child == null || (title == null && content == null && actions == null),
  'LayrzDialog.show: child is an escape hatch that replaces the entire body. '
  'Pass either child alone, or title/content/actions — not both.',
);

— because mixing the two composition modes would leave it ambiguous which one governs layout.


Usage Examples

Confirm / Cancel

final confirmed = await LayrzDialog.show<bool>(
  context,
  title: const Text('Delete item?'),
  content: const Text('This cannot be undone.'),
  actions: [
    LayrzButton.cancel(labelText: 'Cancel', onTap: () => Navigator.of(context).pop(false)),
    LayrzButton.delete(labelText: 'Delete', onTap: () => Navigator.of(context).pop(true)),
  ],
);

Informational (no actions, dismissible by default)

await LayrzDialog.show<void>(
  context,
  title: const Text('Sync complete'),
  content: const Text('All changes were saved.'),
);

The child Escape Hatch

await LayrzDialog.show<void>(
  context,
  child: _buildCustomStepperContent(),
);

Behavior

Dismissal and the Double-Pop Guard

A modal dialog is dismissed by a barrier tap, Escape, the X close icon, or the system/Android back gesture — all gated together by canDismiss (see below) — each of which calls Navigator.pop(), but never directly. Every dismiss site calls LayrzModalRoute.popIfCurrent(context), the single guard implementation shared with LayrzBottomSheet, defined once at lib/src/sheets/src/modal_route.dart:55:

static void popIfCurrent(BuildContext context) {
  if (ModalRoute.of(context)?.isCurrent ?? false) {
    Navigator.of(context).pop();
  }
}

This exists because of a release-only data-loss bug fixed in 0.0.14: the barrier stays mounted and hit-testable for the entire exit transition, including the slide/fade-out animation. A second fast tap on the barrier during that window would otherwise call Navigator.pop() on a route that is already in the process of popping — which, in a plain Navigator, silently popped the caller's own page underneath the dialog, with no error to signal it happened. isCurrent is checked immediately before popping, so the second tap in a fast double-tap becomes a no-op instead of a second pop.

Because LayrzDialog and LayrzBottomSheet both extend LayrzModalRoute, there is exactly one implementation of this guard in the codebase — not one per surface that could drift or be forgotten in a future one. Any future modal-surface addition that extends LayrzModalRoute gets the same guard automatically, by construction, rather than by remembering to copy it.

canDismiss

canDismiss governs whether the dialog can be dismissed by any route other than one of its own actions. It is a single flag gating four routes together, not just the barrier:

  1. tapping the barrier outside the panel,
  2. the Escape key,
  3. the X close icon, and
  4. the system/Android back gesture.

Defaults to null, which infers actions == nullnot to a fixed true or false. The reasoning:

  • A dialog with no actions is informational — there is no decision or unsaved input a stray click could discard, so all four routes dismiss freely, matching a sheet's default barrier behaviour.
  • A dialog with actions is decision-bearing: a dialog carrying actions is answered, not escaped. A stray tap outside it, a reflexive Escape, an incidental X tap, or a back-gesture swipe must not silently stand in for actually choosing, so all four default to blocked.

Either default can be overridden explicitly in either direction — a non-destructive confirm/cancel pair can still pass canDismiss: true if the caller judges the stakes low enough, which deliberately re-opens all four routes at once.

When actions is non-null and canDismiss stays false, the X close icon is not rendered at all — not rendered-but-inert. A visible-but-disabled dismiss icon sitting next to buttons that are the only real way out would be misleading UI, so the icon is simply absent; the only way out of such a dialog is one of its own actions. See The X Close Affordance below.

This parameter used to be named barrierDismissible and governed only the barrier; it was renamed and broadened to a single switch governing every non-action exit at once, specifically so a decision-bearing dialog cannot be half-escaped through whichever route someone forgot to gate.

The X Close Affordance

When the dialog is dismissible (per canDismiss above), it renders a plain tappable close icon — built from LayrzTappable directly, deliberately not a LayrzButton, since it is a bare dismiss affordance rather than a labeled action. Dismissal goes through the same LayrzModalRoute.popIfCurrent guard as the barrier tap and Escape.

  • When title is supplied, the icon sits at the trailing edge of the title row, vertically centered with the title text.
  • When no title is supplied (including the child escape hatch), the icon instead floats over the panel's top-right corner, inset from the edge, so every dismissible dialog keeps a visible close affordance regardless of which slots are used.

Caveat for child users: because the floating icon paints on top of the body via a Stack, a caller using the child escape hatch owns that area and should leave a little top-right clearance of its own — the icon necessarily sits above whatever is placed there.

When the dialog is not dismissible, the icon is not rendered at all — see canDismiss above.

Stacking Is Not Permitted in v1

Opening a second LayrzDialog while one is already open is not supported. Rather than silently compounding two semi-transparent barriers into one visually darker layer (each barrier paints its own scrim independently), show<T>() asserts against it:

assert(() {
  final current = ModalRoute.of(context);
  if (current is _DialogRoute) {
    throw FlutterError(
      'LayrzDialog.show was called while a LayrzDialog is already open. '
      'Stacking dialogs is not supported in this version — dismiss the '
      'current dialog before opening another.',
    );
  }
  return true;
}());

A caller needing a second decision after the first must dismiss the current dialog before opening the next.

Always the Root Navigator

LayrzDialog.show always pushes on the root navigator (Navigator.of(context, rootNavigator: true)) — this is intrinsic, not caller-configurable. There used to be a useRootNavigator parameter mirroring LayrzBottomSheet's own, but it was removed entirely: only one value was ever correct for a dialog. A dialog shown from a context whose nearest navigator is nested inside the page body — the canonical case being a go_router ShellRoute, which builds its Navigator inside the page body rather than at the app root — would otherwise land inside that page's own subtree instead of covering the whole screen, with the barrier failing to cover chrome (such as a top bar) that lives outside the nested navigator. Every real call site needed the root navigator, so there was no configuration left to get wrong.

Sizing

The panel is bounded by maxWidth (default 480) and maxHeight (default 640), in logical pixels, and also respects the viewport — a narrow window clamps below maxWidth regardless of the value passed. Content in the content slot taller than maxHeight scrolls internally via a LayrzScrollbar-wrapped scroll view rather than growing the panel or overflowing.

The content Slot's Unbounded Height

content sits inside a SingleChildScrollView — that is what lets content: Text(...) (or any content sized to itself) scroll past maxHeight instead of overflowing. The trade-off is that content receives unbounded height from that scroll view: anything inside it that tries to fill the space it is given — Expanded, Flexible, a bare ListView/GridView with no bound of its own — throws, because there is no bound to fill.

Workarounds, in order of preference:

  • Give the fill-seeking child an explicit height, e.g. SizedBox(height: 300, child: ListView(...)).
  • Set shrinkWrap: true on the list/grid so it sizes to its own content instead of trying to fill.
  • For a layout that genuinely needs to occupy the dialog's whole available height, use the child escape hatch instead — it hands the caller the whole panel body directly, already bounded by maxWidth/maxHeight, with no intermediate scroll view imposing an unbounded constraint. The trade-off there is that child gets no title row or action row for free.

Accessibility

  • Focus restoration. PopupRoute (the base RawDialogRoute extends, which LayrzModalRoute in turn extends) traps focus inside the dialog while it is open, but does not itself restore focus to whatever held it before the dialog was pushed. LayrzDialog handles this explicitly: the previously-focused node is captured in initState (via a post-frame callback, before the dialog's own focus node requests focus) and restored in dispose. The restoration is guarded on FocusNode.canRequestFocus — the previously-focused node may have been disposed independently while the dialog was open, and a disposed node reports canRequestFocus == false rather than throwing, so the guard turns that case into a no-op instead of a crash on teardown.
  • Barrier label. Comes from context.l10n.dialogsBarrierLabel, the dialog's own localized string — deliberately not shared with LayrzBottomSheet's sheetsBarrierLabel, even though both default to the same English text today (see Placement in the UI below for why they are kept distinct).
  • semanticLabel. When supplied, wraps the panel in Semantics(scopesRoute: true, namesRoute: true, explicitChildNodes: true), announcing the dialog's purpose alongside the barrier label. If omitted, no route semantics are added at all, rather than announcing an unlabeled route.
  • Escape dismisses the dialog, guarded by the same isCurrent check as the barrier tap, via LayrzModalRoute.popIfCurrent.
  • Reduce motion is respected: the fade/scale transition is pinned to its end value instead of animating when MediaQuery.of(context).disableAnimations is true, via the shared LayrzModalRoute.resolveAnimation helper.

Placement in the UI

LayrzDialog unblocks the eighteen dialog-opening consumers already specified elsewhere in the design system: M3's LayrzSelectInput, LayrzMultiSelectInput, LayrzDualListInput, and all fifteen M4 picker components. Per D52's amendment, shipped M3 pickers keep their existing LayrzAnchoredPanel desktop surface untouched; LayrzDialog is available to future consumers, not a retrofit onto shipped code. A later M4 picker whose content is genuinely a small form may still choose LayrzDialog on its own merits — that is a per-component decision, not a blanket migration.

Gets its own barrier label (dialogsBarrierLabel) rather than sharing LayrzBottomSheet's sheetsBarrierLabel, because the two surfaces are presented independently and may need to diverge in translation even though both currently read "Dialog box" in English.


Related


Last updated: 2026-08-27 Status: Shipped

Clone this wiki locally