-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzAnchoredPanel
A Material-free anchored overlay panel that positions a floating panel on any of the four sides of an anchor widget, automatically flipping to the opposite side if there is insufficient space. Designed as the desktop presentation layer for picker inputs.
Metadata
Mirrors: None (new component for layrz_ui)
Phase: M3 (Picker Inputs)
Domain: Overlays and Pickers
Primitive: RawMenuAnchor + CustomSingleChildLayout + SingleChildScrollView
Status: Confirmed scope.
LayrzAnchoredPanel displays a floating panel anchored to a trigger widget, with the panel positioning itself on preferredSide of the anchor and flipping unconditionally to the opposite side if there is insufficient space. This component is similar to a web combobox dropdown and provides the desktop presentation layer for picker inputs.
The trigger is built with access to the panel controller, allowing callers to wire their own tap or other event handlers directly to controller.open(), controller.close(), or toggle logic based on controller.isOpen.
-
Flexible positioning: Panel positions on
preferredSideof the anchor (defaultLayrzPreferredSide.bottom— existing call sites do not move), but flips unconditionally to the opposite side if there is insufficient space on the preferred side. There is no second fit test: when neither side fits, the panel lands on the opposite side and is clamped into the overlay. -
Configurable width policy:
-
matchAnchor: Panel width equals the anchor's width (default, suitable for input fields). -
contentSized: Panel width is sized to content and clamped to min/max bounds (suitable for icon buttons or narrow anchors). - In both cases, the resulting width is additionally clamped to the space actually available in the overlay, so the panel shrinks instead of overflowing on a narrow viewport.
-
- Content scrolling: Panel supports an optional maximum height; content taller than this value scrolls inside the panel.
- Full keyboard and accessibility support: Escape key dismisses, tap-outside closes, keyboard traversal into panel content is supported.
- Minimal animation: Enter animation is a simple fade transition. Exit is synchronous.
-
Cross-axis alignment: Panel alignment along the cross axis of its resolved side (start, center, end) with automatic clamping if space is insufficient. The cross axis is horizontal when the panel is placed above or below the anchor, and vertical when it is placed to the left or right — the same axis-relative convention Flutter uses for
CrossAxisAlignment.
class LayrzAnchoredPanel extends StatefulWidget {
/// Builds the anchor/trigger widget that opens/closes the panel.
///
/// The builder receives the panel [controller], which should be wired to the
/// anchor's event handlers. For example:
/// ```dart
/// builder: (context, controller) => LayrzButton(
/// labelText: 'Open',
/// onTap: controller.isOpen ? controller.close : controller.open,
/// )
/// ```
/// The controller exposes [isOpen] for toggle behavior.
final LayrzAnchoredPanelBuilder builder;
/// The content to display in the anchored panel.
///
/// This widget is constrained by [maxHeight] and overlay bounds, with scrolling
/// applied if content overflows. Content is wrapped in a [SingleChildScrollView].
final Widget child;
/// How to size the panel's width.
///
/// - [LayrzAnchoredPanelWidthPolicy.matchAnchor]: Width equals anchor width (default).
/// Use this for input fields where the dropdown should match the field width.
/// - [LayrzAnchoredPanelWidthPolicy.contentSized]: Width is sized to content,
/// clamped to [widthBounds]. Use this for icon buttons or other small anchors.
final LayrzAnchoredPanelWidthPolicy widthPolicy;
/// Width bounds for content-sized policy.
///
/// Only used when [widthPolicy] is [LayrzAnchoredPanelWidthPolicy.contentSized].
/// Specifies the minimum and maximum width of the panel.
/// Default: `LayrzAnchoredPanelWidthBounds(minWidth: 160.0, maxWidth: 320.0)`
final LayrzAnchoredPanelWidthBounds widthBounds;
/// Optional maximum height for the panel's content in logical pixels.
///
/// When null, the panel height is constrained only by overlay bounds minus padding.
/// When set, content taller than this value will scroll inside the panel.
final double? maxHeight;
/// Space between the anchor and panel in logical pixels.
///
/// Default: 4.0. The gap is applied on the perpendicular axis
/// (vertical when panel is below/above anchor).
final double gap;
/// Alignment of the panel along the cross axis of its resolved side.
///
/// Default: [LayrzAnchoredPanelAlignment.start]
/// The panel is positioned according to this alignment and then clamped
/// into the overlay bounds.
final LayrzAnchoredPanelAlignment alignment;
/// The preferred side on which the panel is placed relative to the anchor.
///
/// Default: [LayrzPreferredSide.bottom] — existing call sites do not move.
/// If the panel does not fit on this side, it flips unconditionally to
/// the opposite side; there is no second fit test.
final LayrzPreferredSide preferredSide;
/// Optional controller for programmatic control of the panel's open/close state.
///
/// When null, the panel is owned by the [LayrzAnchoredPanel] and has no
/// external control. When non-null, callers can open or close the panel by
/// calling [controller.open()] and [controller.close()].
///
/// **Important:** The controller instance must never be swapped via [didUpdateWidget].
/// An assertion will fail if a different controller instance is passed on a rebuild.
final MenuController? controller;
/// Called when the panel is opened.
///
/// Fires before the overlay is shown.
final VoidCallback? onOpen;
/// Called when the panel is closed.
///
/// Fires after the panel is removed from the overlay.
final VoidCallback? onClose;
/// Optional focus node passed to the anchor widget for keyboard interaction.
///
/// When the panel is closed, focus returns to this node. The caller must ensure
/// this node outlives the panel widget.
final FocusNode? childFocusNode;
/// Optional callback reporting whether the panel flipped to the side opposite
/// [preferredSide].
///
/// Called with `true` when the panel was placed on the side opposite to
/// [preferredSide], `false` when it landed on [preferredSide] itself. For the
/// default [LayrzPreferredSide.bottom], this is bit-identical to "above" vs
/// "below". Useful for adapting corner radius on the side adjacent to the
/// anchor (e.g., rounding the bottom-left corner differently when the panel
/// is above).
final void Function(bool flippedUp)? onFlipped;
/// Creates a new [LayrzAnchoredPanel].
const LayrzAnchoredPanel({
required this.builder,
required this.child,
this.widthPolicy = LayrzAnchoredPanelWidthPolicy.matchAnchor,
this.widthBounds = const LayrzAnchoredPanelWidthBounds(minWidth: 160.0, maxWidth: 320.0),
this.maxHeight,
this.gap = 4.0,
this.alignment = LayrzAnchoredPanelAlignment.start,
this.preferredSide = LayrzPreferredSide.bottom,
this.controller,
this.onOpen,
this.onClose,
this.childFocusNode,
this.onFlipped,
super.key,
});
}/// Specifies how the anchored panel should size its width.
enum LayrzAnchoredPanelWidthPolicy {
/// Width exactly matches the anchor widget's width.
matchAnchor,
/// Width is sized to the panel's content, clamped between min/max bounds.
contentSized,
}
/// Bounds for content-sized width policy.
class LayrzAnchoredPanelWidthBounds {
/// Minimum width of the panel in logical pixels.
final double minWidth;
/// Maximum width of the panel in logical pixels.
final double maxWidth;
const LayrzAnchoredPanelWidthBounds({
required this.minWidth,
required this.maxWidth,
});
}
/// Specifies the alignment of an anchored panel along the cross axis of its resolved side.
///
/// The cross axis is horizontal when the panel is placed above or below the anchor,
/// and vertical when it is placed to the left or right — the same axis-relative
/// convention Flutter uses for `CrossAxisAlignment`.
enum LayrzAnchoredPanelAlignment {
/// Panel's leading cross-axis edge aligns with the anchor's leading edge:
/// the left edges on a vertical side, the top edges on a horizontal side.
///
/// `start` is the leading edge in left-to-right layouts. `Directionality` is
/// not yet honoured; in right-to-left layouts this still means the left edge.
start,
/// Panel's cross-axis center aligns with the anchor's cross-axis center:
/// horizontal centers on a vertical side, vertical centers on a horizontal side.
center,
/// Panel's trailing cross-axis edge aligns with the anchor's trailing edge:
/// the right edges on a vertical side, the bottom edges on a horizontal side.
///
/// `end` is the trailing edge in left-to-right layouts. `Directionality` is
/// not yet honoured; in right-to-left layouts this still means the right edge.
end,
}LayrzAnchoredPanel(
builder: (context, controller) => LayrzTextInput(
value: selectedValue,
onTap: controller.isOpen ? controller.close : controller.open,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: options.map((option) => _buildOption(option)).toList(),
),
)LayrzAnchoredPanel(
widthPolicy: LayrzAnchoredPanelWidthPolicy.contentSized,
widthBounds: const LayrzAnchoredPanelWidthBounds(minWidth: 150.0, maxWidth: 250.0),
builder: (context, controller) => LayrzButton.icon(
icon: Icons.more_vert,
onTap: controller.isOpen ? controller.close : controller.open,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_MenuItem('Edit'),
_MenuItem('Delete'),
_MenuItem('Share'),
],
),
)LayrzAnchoredPanel(
maxHeight: 300.0,
builder: (context, controller) => LayrzButton(
labelText: 'Select',
onTap: controller.isOpen ? controller.close : controller.open,
),
child: Column(
children: largeList.map((item) => _buildItem(item)).toList(),
),
)final panelController = MenuController();
LayrzAnchoredPanel(
controller: panelController,
builder: (context, _) => LayrzButton(labelText: 'Trigger'),
child: _buildPanelContent(),
)
// Later, open or close programmatically:
panelController.open();
panelController.close();LayrzAnchoredPanel places itself on one of the four sides of its anchor — top, bottom, left, or right — via preferredSide, a LayrzPreferredSide value shared with LayrzTooltip. See LayrzTooltip for the type itself.
-
Preferred side (default: bottom): Panel is positioned on
preferredSideof the anchor, separated by thegap(default 4.0pt). Existing call sites, which do not passpreferredSide, keep today's below-by-default placement unchanged. -
Unconditional flip: If the panel does not fit on
preferredSide, it flips to the opposite side. There is no second fit test against the opposite side, and no third fallback. -
Neither side fits: The panel lands on the opposite side regardless, and is clamped into the overlay. For the default
bottom, this means an over-tall panel now clamps to the overlay's top edge rather than its bottom edge — a behaviour change from earlier releases, reachable today viaLayrzDurationInput's desktop panel on a short viewport. -
Clamping: Panel position is adjusted to stay within overlay bounds on both axes. Panel width is additionally clamped to the space actually available in the overlay, so a
contentSizedpanel shrinks instead of overflowing on a narrow viewport.
matchAnchor combined with a horizontal preferredSide is not a recommended combination. matchAnchor fixes the panel's width to the anchor's own width, but a horizontal side needs a full extra anchor-width of clear space beside the anchor to avoid being squeezed — space that is rarely available. The width is still clamped safely (it will never overflow), but the panel will usually render narrower than the anchor. Prefer contentSized when using a horizontal preferredSide.
RTL gap: alignment's start/end values are not yet Directionality-aware — start always means the left edge (or top edge, on a horizontal side) regardless of text direction. This is a known gap, not addressed by this side-vocabulary change.
-
matchAnchor (default): Panel width = anchor width. Suitable for input fields. Not recommended with a horizontal
preferredSide— see above. -
contentSized: Panel width is sized to natural content size, then clamped to
[minWidth, maxWidth], further clamped to the overlay's available width. Suitable for icon buttons or compact anchors.
- Without
maxHeight: Content height is constrained only by available overlay space minus padding. - With
maxHeight: Content height is further constrained tomaxHeight; content taller than this scrolls.
- Escape key: Closes the panel and returns focus to the anchor.
- Tab/Arrow keys: Keyboard traversal works into the panel content.
-
Tap outside: Closes the panel via
TapRegion. - Semantic labels: Panel content retains its own semantics; outer container is transparent to a11y tools.
| Aspect | LayrzAnchoredPanel | LayrzDropdownMenu |
|---|---|---|
| Width sizing | Configurable (matchAnchor or contentSized with bounds) | Fixed bounds [160, 320] |
| Content type | Arbitrary widget | Sealed hierarchy (Entry, Label, Divider) |
| Use case | Picker inputs, flexible dropdowns | Static menus with entries |
| Typical consumer | LayrzSelectInput, LayrzComboBoxInput | Standalone menu actions |
LayrzAnchoredPanel is used by:
-
LayrzSelectInput(desktop) — dropdown list of options -
LayrzComboBoxInput(desktop) — editable dropdown with filtered list -
LayrzSearchInput.iconmode (desktop) — search results panel anchored to icon -
LayrzDurationInput(desktop) — time picker panel
On mobile/compact viewports (context.isCompact, width < 960px), these inputs use bottom sheets instead.
- Focus management: Focus moves to the panel when opened; returns to anchor on close.
- Keyboard navigation: Users can navigate panel content with arrow keys and Tab.
- Escape key: Panel closes, restoring focus to the anchor.
- Semantic clarity: Content inside the panel keeps its own semantic labels; the panel itself is a transparent container.
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput