-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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 tapping the barrier outside the dialog dismisses it. Defaults
/// to `true` when actions is null, `false` when actions is non-null.
bool? barrierDismissible,
/// Optional semantic label describing the dialog's purpose for screen
/// readers, announced alongside the barrier label.
String? semanticLabel,
/// Whether to use the root navigator instead of the nearest one.
/// Defaults to `false`.
bool useRootNavigator = false,
/// 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>().
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.
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)),
],
);await LayrzDialog.show<void>(
context,
title: const Text('Sync complete'),
content: const Text('All changes were saved.'),
);await LayrzDialog.show<void>(
context,
child: _buildCustomStepperContent(),
);// Inside a page hosted by a go_router ShellRoute, where the nearest
// Navigator is the shell's own nested one:
await LayrzDialog.show<void>(
context,
useRootNavigator: true,
content: const Text('...'),
);A modal dialog is dismissed by a barrier tap (when barrierDismissible is true) or Escape, both of which call Navigator.pop() — but never directly. Both dismiss sites call 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.
Defaults to actions == null — not 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 it dismisses freely, matching a sheet's default barrier behaviour.
- A dialog with actions is decision-bearing. A stray tap outside it must not silently discard whatever the user was choosing between (or any input already entered), so it defaults to non-dismissible.
Either default can be overridden explicitly in either direction — a non-destructive confirm/cancel pair can still pass barrierDismissible: true if the caller judges the stakes low enough.
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.
Defaults to false (nearest Navigator). Set to true when the nearest Navigator is nested inside an app shell that owns its own Navigator — the canonical case is a go_router ShellRoute, which builds a Navigator for the routes it hosts, nested inside the page body. Without it, the dialog can land inside that page's own layout instead of covering the whole screen, and its barrier may not cover chrome (such as a top bar) that lives outside the nested navigator. This is the same trap LayrzBottomSheet documents for the same flag — see LayrzBottomSheet.
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.
-
Focus restoration.
PopupRoute(the baseRawDialogRouteextends, whichLayrzModalRoutein 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.LayrzDialoghandles this explicitly: the previously-focused node is captured ininitState(via a post-frame callback, before the dialog's own focus node requests focus) and restored indispose. The restoration is guarded onFocusNode.canRequestFocus— the previously-focused node may have been disposed independently while the dialog was open, and a disposed node reportscanRequestFocus == falserather 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 withLayrzBottomSheet'ssheetsBarrierLabel, 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 inSemantics(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
isCurrentcheck as the barrier tap, viaLayrzModalRoute.popIfCurrent. -
Reduce motion is respected: the fade/scale transition is pinned to its end value instead of animating when
MediaQuery.of(context).disableAnimationsistrue, via the sharedLayrzModalRoute.resolveAnimationhelper.
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.
-
LayrzBottomSheet — the sibling modal surface sharing
LayrzModalRoute; documents the same double-pop guard and root-navigator rationale from its own side -
LayrzResponsiveModal — the viewport-driven chooser between this component and
LayrzBottomSheet - LayrzAnchoredPanel — the field-relative, non-modal surface this component is explicitly not a replacement for
-
Repo: D65 — LayrzLayout Resizes Its Body for the Keyboard, Scaffold-Style — the same keyboard-inset problem family
LayrzModalRouteinherits from the sheet
Last updated: 2026-08-27 Status: Shipped
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider