-
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 the modal can be dismissed by any route other than an
/// explicit action (barrier tap, Escape, the X icon on the dialog branch,
/// drag-dismiss on the sheet branch, and the system/Android back gesture
/// on both). Forwarded to whichever branch is chosen — see "The
/// `canDismiss` Defaulting Asymmetry" below for the one detail that still
/// differs between the two branches when this is left `null`.
bool? canDismiss,
/// Semantic label for screen readers, forwarded to whichever branch is
/// chosen. Must be equivalent regardless of surface — see Accessibility.
String? semanticLabel,
/// 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, canDismiss, semanticLabel — sit directly on show<T>() rather than being duplicated into both config objects. canDismiss is forwarded to both branches (see The canDismiss Defaulting Asymmetry below for the one place its behaviour still differs between them).
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.
canDismiss is forwarded to both branches — this used to be dialog-branch-only, silently dropped on the sheet branch, so a caller passing canDismiss: false got a non-dismissible dialog on a wide viewport and a freely-dismissible sheet on a narrow one. That gap is closed: both branches now honor the same flag, so the resolved presentation no longer changes what canDismiss means for the caller.
One detail still differs, in how an unset (null) canDismiss defaults on each branch — a consequence of the two branches' own signatures, not a leftover bug:
-
LayrzDialog.show's
canDismissis nullable (bool?). Passed straight through,nullinfers fromactions: dismissible when noactionswere supplied, not dismissible when they were. -
LayrzBottomSheet.show's
canDismissis non-nullable (bool, defaulttrue) — the sheet has noactionsslot to infer a conservative default from, so it has nothing to inferfalseagainst.LayrzResponsiveModalmaps anulloverride totrueon this branch (canDismiss ?? true), rather than passingnullthrough (which the sheet's signature would not even accept) or defaulting tofalse.
Concretely: leaving canDismiss unset gives a dialog that infers its default from actions, and a sheet that is unconditionally dismissible by default — matching what calling LayrzBottomSheet.show directly would already give you, since this wrapper's default is chosen specifically to leave that method's own behaviour unchanged. Passing canDismiss explicitly (true or false) applies identically to both branches, with no asymmetry left to reason about.
LayrzDialog offers structured title/content/actions slots plus a child escape hatch, and LayrzBottomSheet now offers its own actions slot alongside builder (see LayrzDialog and LayrzBottomSheet). LayrzResponsiveModal exposes none of that — only a single builder, with no actions/title/content parameter of its own on show<T>().
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. Neither branch's actions (or, on the dialog side, title/content) is reachable through this wrapper — there is no way for one builder result to populate one branch's slots without the other branch simply not knowing what to do with them, and LayrzResponsiveModal.show does not accept or forward an actions list to either.
A caller who needs a structured actions row — on either branch — more than they need responsive presentation should call LayrzDialog.show or LayrzBottomSheet.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