-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzResponsiveModal
A thin chooser that presents its content as a LayrzDialog on wide viewports and a LayrzBottomSheet on narrow ones, adding no behaviour of its own.
Metadata
Mirrors: None (new component for layrz_ui)
Phase: M2 Core primitives
Domain: Layout
Primitive: composes LayrzDialog + LayrzBottomSheet
Status: Shipped.
LayrzResponsiveModal exists to remove one duplicated breakpoint branch from consumers that would otherwise write "dialog on desktop, sheet on mobile" themselves. It is a static-method surface — LayrzResponsiveModal.show<T>() — resolves which underlying surface to use, and forwards entirely to it. Both LayrzDialog.show<T>() and LayrzBottomSheet.show<T>() return Future<T?> where null means dismissed, so this wrapper's contract is identical to either branch on its own; callers never need to branch on which surface was actually used.
The name is deliberate: LayrzResponsiveModal, not LayrzAdaptiveModal. Flutter reserves "adaptive" for platform-switching (Switch.adaptive picks Material vs. Cupertino by platform); this component switches on viewport breakpoint, which is what "responsive" means in this codebase's own vocabulary (LayrzBreakpoint, LayrzRow). The rename happened before publication specifically because renaming a published 0.x API later is a breaking change, and the reasoning was judged worth taking now rather than deferring.
This governs the whole component and deserves to be read before anything else on this page: presentation is resolved once, and never re-evaluated. See The Decide-Once Rule below — it is stated first, prominently, because the name "responsive" invites the opposite assumption harder than "adaptive" ever did.
class LayrzResponsiveModal {
/// Shows the content as a dialog or a bottom sheet, chosen once at call time.
static Future<T?> show<T>(
BuildContext context, {
/// Builds the modal's content. Receives the surface's own context.
/// For the dialog branch, the built widget is passed as LayrzDialog.show's
/// `child` escape hatch — see "The Builder-vs-Slots Limitation" below.
required WidgetBuilder builder,
/// Overrides which surface is chosen, regardless of viewport width.
/// Defaults to `context.isCompact`. Pass `true` to force the sheet or
/// `false` to force the dialog.
bool? isCompact,
/// Whether tapping the barrier dismisses the modal. Only meaningful for
/// the dialog branch — LayrzBottomSheet's barrier is always dismissible,
/// so this is ignored entirely when the sheet branch is chosen.
bool? barrierDismissible,
/// Semantic label for screen readers, forwarded to whichever branch is
/// chosen. Must be equivalent regardless of surface — see Accessibility.
String? semanticLabel,
/// Whether to use the root navigator instead of the nearest one.
/// Forwarded to whichever branch is chosen. Defaults to `false`.
bool useRootNavigator = false,
/// Dialog-branch-only configuration (max width/height). Ignored when the
/// sheet branch is chosen. Defaults to `const LayrzDialogConfig()`.
LayrzDialogConfig dialog = const LayrzDialogConfig(),
/// Sheet-branch-only configuration (snap sizes, initial/min/max size,
/// drag handle, scrollable). Ignored when the dialog branch is chosen.
/// Defaults to `const LayrzBottomSheetConfig()`.
LayrzBottomSheetConfig sheet = const LayrzBottomSheetConfig(),
});
}Branch-specific parameters are grouped into LayrzDialogConfig (maxWidth, maxHeight) and LayrzBottomSheetConfig (snapSizes, initialSize, minSize, maxSize, showDragHandle, scrollable) rather than flattened onto show<T>() as one superset of parameters.
This is deliberate, not incidental grouping: snapSizes is meaningless for a dialog (a dialog has no drag-to-resize), and maxWidth/maxHeight are meaningless for a sheet (a sheet is sized by viewport height fractions, not a fixed pixel box). A flattened superset would show every caller both branches' parameters regardless of which one applies to them, which is worse than two small config objects — a caller configuring the dialog branch is simply never shown LayrzBottomSheetConfig.snapSizes in the first place. Builder callbacks were also considered and rejected: a callback shape cannot carry a /// doc comment per argument the way named fields on a config object can, which the codebase requires for every public parameter.
Truly shared parameters — builder, barrierDismissible, semanticLabel, useRootNavigator — sit directly on show<T>() rather than being duplicated into both config objects.
final selected = await LayrzResponsiveModal.show<String>(
context,
semanticLabel: 'Choose an option.',
builder: (context) => _buildOptionList(context),
);
// Presented as a LayrzDialog at >= 960px viewport width,
// a LayrzBottomSheet below it — decided once, at this call.// A picker with a long option list may want the sheet's full-height
// scrolling even on a desktop-width viewport.
await LayrzResponsiveModal.show<void>(
context,
isCompact: true,
builder: (context) => _buildLongOptionList(context),
);await LayrzResponsiveModal.show<void>(
context,
builder: (context) => _buildDetail(context),
dialog: const LayrzDialogConfig(maxWidth: 560, maxHeight: 720),
sheet: const LayrzBottomSheetConfig(snapSizes: [0.4, 0.9]),
);The presentation is resolved via context.isCompact — true below the sm/md breakpoint boundary at 960 logical pixels, false at or above it — the same boundary D52 already uses for this exact dialog-vs-sheet choice in the M3 picker family.
Container width via LayoutBuilder was considered and rejected. LayrzLayout's own resolveLayrzLayoutPresentation deliberately reads LayoutBuilder constraint width instead of viewport width, because LayrzLayout can operate inside a constrained container (an embedded shell) and its own presentation should respond to the space it is actually given. A modal is a different case: it is presented over the whole screen, regardless of where LayrzResponsiveModal.show was called from, so the honest input is the viewport itself. Feeding the resolver a constrained container's width instead would make the dialog-vs-sheet choice depend on an accident of where the calling widget happens to sit in the tree, rather than on the screen the user is actually looking at.
Presentation is resolved exactly once, at show() call time, and is never re-evaluated for the life of the route. This is the single most important thing to understand about this component, and it is stated prominently rather than left as a footnote precisely because the name "responsive" invites the opposite assumption harder than the earlier name "adaptive" did.
Concretely: there is no LayoutBuilder, no MediaQuery listener, and nothing installed in the widget tree that could rebuild across the breakpoint. A modal opened on a wide window and then resized narrow stays on the surface it opened with for the entire life of that route — it does not tear down a dialog and push a sheet, or vice versa, when the viewport crosses 960px mid-route.
This is deliberate, not a missing feature: re-evaluating the presentation across a resize is behaviour, and LayrzResponsiveModal's entire premise is that it adds none — it picks a presentation once and forwards, and anything that cannot be expressed as "forward to one branch or the other" belongs on the underlying component, not here.
There is a shipped precedent for the exact failure mode this avoids: version 0.0.14 fixed LayrzScaffoldShell throwing setState() or markNeedsBuild() called during build when the viewport crossed the compact breakpoint while its detail sheet was open — a re-evaluate-on-resize design produces exactly that class of bug. Both directions of this rule are covered by tests: one confirms the presentation does not change when the viewport crosses the breakpoint while the route is already open, and a companion test confirms the same for the reverse crossing.
isCompact defaults to context.isCompact but can be overridden explicitly in either direction — pass true to force the sheet branch even on a wide viewport (a picker with a long option list may prefer the sheet's full-height scroll over a cramped dialog), or false to force the dialog branch even on a narrow one. The override is read once, at the same call-time moment as the default — it does not change the decide-once rule above.
LayrzDialog offers structured title/content/actions slots plus a child escape hatch (see LayrzDialog). LayrzResponsiveModal exposes none of those slots — only a single builder.
This is a deliberate API limitation, not an oversight. The same builder result must serve both branches: it is passed as LayrzDialog.show's child escape hatch on the dialog branch, and directly as LayrzBottomSheet.show's builder on the sheet branch. LayrzBottomSheet has no equivalent slotted shape to receive a separate title/content/actions, so there is no way for one builder to populate LayrzDialog's slots without the sheet branch simply not knowing what to do with them.
A caller who needs the dialog's structured slots more than they need responsive presentation should call LayrzDialog.show directly instead of going through this wrapper.
The presentation choice is expressed as its own enum, LayrzModalPresentation (dialog / sheet), and resolved by a free function:
LayrzModalPresentation resolveLayrzModalPresentation({
required double width,
required LayrzTokens tokens,
});Deliberately not LayrzLayoutPresentation. That enum (expanded / drawer, from lib/src/layout/) means navigation chrome — a fixed rail versus an off-canvas drawer — and neither of its members can be repurposed to mean "dialog" or "sheet" without redefining what the enum stands for everywhere else it is used. A modal surface is a different concept from navigation chrome, so it gets its own enum.
resolveLayrzModalPresentation borrows its shape from resolveLayrzLayoutPresentation — a free function, testable directly at its boundaries without pumping a widget — while feeding it viewport width instead of container width, per the rejection above.
Both branches must announce equivalently to assistive technology — the breakpoint that put a given user on a dialog versus a sheet must never change what they hear, since semanticLabel is forwarded verbatim to whichever branch is chosen. This is covered by a dedicated test asserting both branches announce the same semantic label.
LayrzResponsiveModal is available to any future consumer that would otherwise duplicate the "dialog on desktop, sheet on mobile" branch itself. Consistent with D52's amendment, shipped M3 pickers were not migrated onto it — they keep their existing LayrzAnchoredPanel/LayrzBottomSheet pairing untouched. This component is new plumbing for new consumers, not a retrofit.
- LayrzDialog — the wide-viewport branch
- LayrzBottomSheet — the narrow-viewport branch
-
LayrzLayout — owns
LayrzLayoutPresentation, the navigation-chrome enum this component's own enum is deliberately not reused from - Repo: D52 — Picker Surfaces Are Adaptive
Last updated: 2026-08-27 Status: Shipped
Made with ❤️ by Golden M, Inc.
- LayrzAnchoredPanel
- LayrzBottomSheet
- LayrzDialog
- LayrzDropdownMenu
- LayrzResponsiveModal
- LayrzPageTransition
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider
- LayrzStepper