diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart index 7a51c32..8436a6b 100644 --- a/apps/gallery/lib/main.dart +++ b/apps/gallery/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:m3e_collection/m3e_collection.dart'; +import 'package:m3e_gallery/sections/button_group_section.dart'; import 'package:m3e_gallery/sections/button_section.dart'; import 'package:m3e_gallery/sections/fab_section.dart'; import 'package:m3e_gallery/sections/icon_button_section.dart'; @@ -124,6 +125,7 @@ class SectionedGallery extends StatelessWidget { return ListView( padding: const EdgeInsets.all(16), children: [ + const ButtonGroupSection(), const IconButtonSection(), const SplitButtonSection(), const ButtonSection(), diff --git a/apps/gallery/lib/sections/button_group_section.dart b/apps/gallery/lib/sections/button_group_section.dart new file mode 100644 index 0000000..f820006 --- /dev/null +++ b/apps/gallery/lib/sections/button_group_section.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; + +import 'section_card.dart'; + +class ButtonGroupSection extends StatelessWidget { + const ButtonGroupSection({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionCard( + title: 'ButtonGroupM3E — basic (menu overflow default)', + child: _demoBasic(context), + ), + SectionCard( + title: 'ButtonGroupM3E — scroll overflow', + child: _demoScroll(context), + ), + SectionCard( + title: 'ButtonGroupM3E — wrap layout', + child: _demoWrap(context), + ), + SectionCard( + title: 'ButtonGroupM3E — connected', + child: _demoConnected(context), + ), + SectionCard( + title: 'ButtonGroupM3E — connected with selection', + child: _demoConnectedSelection(context), + ), + SectionCard( + title: 'ButtonGroupM3E — connected, menu overflow', + child: _demoConnectedMenu(context), + ), + SectionCard( + title: 'ButtonGroupM3E — vertical, menu overflow', + child: _demoVertical(context), + ), + ], + ); + } + + Widget _demoBasic(BuildContext context) { + return SizedBox( + width: 280, + child: ButtonGroupM3E( + actions: [ + for (final label in [ + 'One', + 'Two', + 'Three', + 'Four', + 'Five Button', + 'Six' + ]) + ButtonGroupM3EAction(label: Text(label), onPressed: () {}), + ], + ), + ); + } + + Widget _demoScroll(BuildContext context) { + return SizedBox( + width: 280, + child: ButtonGroupM3E( + overflow: ButtonGroupM3EOverflow.scroll, + actions: [ + for (final label in ['A', 'B', 'C', 'D', 'E', 'F']) + ButtonGroupM3EAction( + label: Text('Item $label'), + onPressed: () {}, + style: ButtonM3EStyle.filled, + ), + ], + ), + ); + } + + Widget _demoWrap(BuildContext context) { + return ButtonGroupM3E( + wrap: true, + spacing: 8, + runSpacing: 8, + actions: [ + for (final i in List.generate(10, (i) => i)) + ButtonGroupM3EAction(label: Text('Wrap $i'), onPressed: () {}), + ], + ); + } + + Widget _demoConnected(BuildContext context) { + return SizedBox( + width: 300, + child: ButtonGroupM3E( + type: ButtonGroupM3EType.connected, + dividerColor: Theme.of(context).colorScheme.outlineVariant, + style: ButtonM3EStyle.tonal, + actions: [ + for (final label in ['Low', 'Med', 'High']) + ButtonGroupM3EAction( + label: Text(label), + onPressed: () {}, + ), + ], + ), + ); + } + + Widget _demoConnectedSelection(BuildContext context) { + return SizedBox( + width: 300, + child: ButtonGroupM3E( + type: ButtonGroupM3EType.connected, + dividerColor: Theme.of(context).colorScheme.outlineVariant, + style: ButtonM3EStyle.tonal, + selection: true, + actions: [ + for (final label in ['Low', 'Med', 'High']) + ButtonGroupM3EAction( + label: Text(label), + onPressed: () {}, + ), + ], + ), + ); + } + + Widget _demoConnectedMenu(BuildContext context) { + return SizedBox( + width: 300, + child: ButtonGroupM3E( + type: ButtonGroupM3EType.connected, + dividerColor: Theme.of(context).colorScheme.outlineVariant, + style: ButtonM3EStyle.tonal, + actions: [ + for (final label in [ + 'One', + 'Two', + 'Three', + 'Four', + 'Five Button', + 'Six' + ]) + ButtonGroupM3EAction( + label: Text(label), + onPressed: () {}, + ), + ], + ), + ); + } + + Widget _demoVertical(BuildContext context) { + return SizedBox( + height: 160, + child: ButtonGroupM3E( + direction: Axis.vertical, + actions: [ + for (final label in ['Top', 'Middle', 'Bottom', 'Extra']) + ButtonGroupM3EAction(label: Text(label), onPressed: () {}), + ], + ), + ); + } +} diff --git a/packages/button_group_m3e/CHANGELOG.md b/packages/button_group_m3e/CHANGELOG.md index d780806..5275b1f 100644 --- a/packages/button_group_m3e/CHANGELOG.md +++ b/packages/button_group_m3e/CHANGELOG.md @@ -1,2 +1,31 @@ +## 0.3.0 +- Breaking: Removed legacy `children` parameter. Use `actions: List` instead. +- Breaking: Renamed `groupSelection` API to `selection` for clarity. +- Added `ButtonGroupM3EAction` as the sole way to define buttons in a group. +- Added connected selection corner logic: only outer ends round; inner corners use token square radius; selected buttons fully round. +- Added 2px gap between connected buttons (when `showDividers=false`) and before overflow trigger. +- Added per-corner shape application via new `cornerRadiusOverride` in `ButtonM3E` to support mixed rounded/square corners in groups. +- Added new anchored dropdown overflow menu (default) using `overflowMenuStyle: ButtonGroupM3EOverflowMenuStyle.dropdown`; bottom sheet alternative retained (`bottomSheet`). +- Overflow trigger now rendered as a `ButtonM3E` for visual consistency. +- Improved overflow fitting algorithm (exact iterative width packing + epsilon) to eliminate minor RenderFlex pixel overflows. +- Added right-alignment support: maps `crossAxisAlignment` for linear layouts and allows popup buttons to align right. +- Misc: Refined measurement pass; popup menu intrinsic width matches widest action; dismissal on outside tap. + +## 0.2.0 +- New: Overflow handling options for non-wrapping groups via `overflow` parameter: + - `menu` (new default): show as many children as fit and place the rest in a bottom sheet opened by a trailing overflow icon. + - `scroll`: allow scrolling along the axis under bounded constraints. + - `none`: no special handling. +- Behavior: The default overflow changed from `scroll` to `menu` for a better UX in constrained layouts. +- Implementation: Converted `ButtonGroupM3E` to a StatefulWidget to measure child extents and avoid overflow. +- Tests: Added tests for scroll, wrap, vertical, and menu overflow scenarios. + +## 0.1.2 +- Prevent RenderFlex overflow in non-wrapping groups by auto-enabling scroll when axis constraints are bounded. +- Added widget tests covering horizontal/vertical bounded scenarios and wrap behavior. + +## 0.1.1 +- Minor internal adjustments. + ## 0.1.0 - Changelog initialized. diff --git a/packages/button_group_m3e/README.md b/packages/button_group_m3e/README.md index 0b4e113..ae73675 100644 --- a/packages/button_group_m3e/README.md +++ b/packages/button_group_m3e/README.md @@ -1,173 +1,72 @@ # button_group_m3e -Wrapper-only **Button Group** for Material 3 Expressive (M3E). -Arranges arbitrary action buttons and applies **group-level presentation**: type (standard/connected), shape family (round/square), size (XS–XL), density, and layout (axis, wrap). +Material 3 Expressive grouped button layout and overflow management. -> Buttons themselves remain independent (no selection logic). Use your own M3E buttons (`icon_button_m3e`, `split_button_m3e`, etc.). - -## Install (in monorepo) - -Place this folder alongside `m3e_design`: - -``` -packages/ - m3e_design/ - button_group_m3e/ -``` - -`pubspec.yaml` already expects `m3e_design` at `../m3e_design`. - -## API +## Current API (0.3.0) +`children` has been removed. Provide `actions: List`. ```dart -class ButtonGroupM3E extends StatelessWidget { - const ButtonGroupM3E({ - required List children, - ButtonGroupM3EType type = ButtonGroupM3EType.standard, - ButtonGroupM3EShape shape = ButtonGroupM3EShape.round, - ButtonGroupM3ESize size = ButtonGroupM3ESize.md, - ButtonGroupM3EDensity density = ButtonGroupM3EDensity.regular, - Axis direction = Axis.horizontal, - bool wrap = false, - double? spacing, - double? runSpacing, - WrapAlignment alignment = WrapAlignment.start, - WrapAlignment runAlignment = WrapAlignment.start, - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, - bool showDividers = false, - Color? dividerColor, - double? dividerThickness, - bool equalizeWidths = false, - String? semanticLabel, - Clip clipBehavior = Clip.none, +ButtonGroupM3E( + actions: [ + ButtonGroupM3EAction(label: const Text('One'), onPressed: () {}), + ButtonGroupM3EAction(label: const Text('Two'), onPressed: () {}), + ButtonGroupM3EAction(label: const Text('Three'), onPressed: () {}), + ], + overflow: ButtonGroupM3EOverflow.menu, // default +) +``` + +## Actions +```dart +class ButtonGroupM3EAction { + const ButtonGroupM3EAction({ + required Widget label, + Widget? icon, + VoidCallback? onPressed, + bool enabled = true, + ButtonM3EStyle style = ButtonM3EStyle.filled, + bool toggleable = false, + bool selected = false, + ValueChanged? onSelectedChange, + ButtonM3EShape? shape, }); } ``` -Enums: -```dart -enum ButtonGroupM3EType { standard, connected } -enum ButtonGroupM3EShape { round, square } -enum ButtonGroupM3ESize { xs, sm, md, lg, xl } -enum ButtonGroupM3EDensity { regular, compact } -``` - -## Scope for cooperative buttons - -The group exposes: `ButtonGroupM3EScope` and `ButtonGroupM3EItemScope`: - -```dart -final g = ButtonGroupM3EScope.of(context); -final i = ButtonGroupM3EItemScope.of(context); -// g.size, g.shape, g.isConnected, g.direction ... -// i.index, i.count, i.isFirst, i.isLast ... -``` - -Buttons can read these to adopt **height, corner radii (outer vs inner), compact paddings**, etc. - -## Defaults (recommended) - -- type: `standard` -- shape: `round` -- size: `md` -- density: `regular` -- direction: `Axis.horizontal` -- wrap: `false` -- standard spacing: token-based (≈8dp at md) -- connected spacing: `0` -- dividers: `false` by default (connected only) -- dividerThickness: `1dp` (hairline) - -## Notes - -- In **wrap** mode, connected **dividers** are not auto-drawn per run (Flutter Wrap lacks per-run hooks). Use standard type, or accept flush seams. -- `equalizeWidths` uses min-widths by size (40, 56, 72, 96, 120). For true equalization per run, implement a custom multi-pass layout if needed. -- The widget does **not** clip children unless `clipBehavior` is set. Prefer cooperative styling via scope. - -## Example - +## Group selection +Enable segmented styling: ```dart +int selectedIndex = 0; ButtonGroupM3E( - type: ButtonGroupM3EType.connected, - shape: ButtonGroupM3EShape.round, - size: ButtonGroupM3ESize.lg, - showDividers: true, - semanticLabel: 'Playback controls', - children: [ - // Your M3E buttons here... + groupSelection: true, + selectedIndex: selectedIndex, + actions: [ + ButtonGroupM3EAction(label: const Text('Day'), onPressed: () => setState(() => selectedIndex = 0)), + ButtonGroupM3EAction(label: const Text('Week'), onPressed: () => setState(() => selectedIndex = 1)), + ButtonGroupM3EAction(label: const Text('Month'), onPressed: () => setState(() => selectedIndex = 2)), ], ) ``` +Shape rules when `groupSelection` is true: +- Selected button: fully round. +- First & last (unselected): round. +- Middle unselected buttons: square. +## Overflow +- `menu` (default): shows what fits + overflow trigger with remaining actions in a bottom sheet. +- `scroll`: scrolls along main axis when constrained. +- `none`: no handling (may overflow if parent allows). ---- +## Other parameters +- `type`: standard | connected (divider seams, zero spacing) +- `shape`: square | round (base shape family) +- `size`: xs | sm | md | lg | xl +- `density`: regular | compact +- Layout: `direction`, `wrap`, `spacing`, `runSpacing`, alignment options. +- `equalizeWidths`: enforce min widths per size for even visual rhythm. -## Live demo (Gallery) +## Versioning +0.3.0 – BREAKING: removed `children`. Use `actions`. -Explore this component in the M3E Gallery (GitHub Pages): - -https://.github.io/material_3_expressive/ - -To run the Gallery locally: - -```sh -cd apps/gallery -flutter run -d chrome -``` - -_Last updated: 2025-10-23_ - - ---- - -## Detailed Guide - -### What this package provides -A layout-only ButtonGroupM3E that propagates size/shape to its child buttons and ensures consistent spacing and overflow behavior for Material 3 Expressive buttons. - -### Installation -- Monorepo (local path): already configured alongside m3e_design and button_m3e. -- Pub (when published): -```yaml -dependencies: - button_group_m3e: ^0.1.0 - m3e_design: ^0.1.0 - button_m3e: ^0.1.0 -``` - -Minimum SDK: Dart >=3.5.0. - -### Dependencies -- flutter -- m3e_design - -### Quick start -```dart -ButtonGroupM3E( - size: ButtonM3ESize.md, - shapeFamily: ButtonM3EShapeFamily.round, - children: [ - ButtonM3E.filled(onPressed: () {}, label: const Text('One')), - ButtonM3E.outlined(onPressed: () {}, label: const Text('Two')), - ButtonM3E.text(onPressed: () {}, label: const Text('Three')), - ], -) -``` - -### Key parameters -- children: List — Typically ButtonM3E instances. -- size: ButtonM3ESize — xs | sm | md | lg | xl. Propagated to children when possible. -- shapeFamily: ButtonM3EShapeFamily — round | square. -- spacing: double? — Horizontal/vertical gap between children. -- direction / wrap: Axis or wrap behavior depending on implementation. - -### Theming with m3e_design -Spacing/shape defaults are derived from M3ETheme tokens; you can override explicitly per group or per button. - -### Accessibility -- Maintains minimum tap targets for grouped buttons and preserves focus order. - -### Links -- Repository: https://github.com/EmilyMoonstone/material_3_expressive/tree/main/packages/button_group_m3e -- Issue tracker: https://github.com/EmilyMonestone/material_3_expressive/issues -- Changelog: ./CHANGELOG.md +## License +See LICENSE. diff --git a/packages/button_group_m3e/lib/button_group_m3e.dart b/packages/button_group_m3e/lib/button_group_m3e.dart index 0a98f41..d2b031a 100644 --- a/packages/button_group_m3e/lib/button_group_m3e.dart +++ b/packages/button_group_m3e/lib/button_group_m3e.dart @@ -3,4 +3,5 @@ library button_group_m3e; export 'src/button_group_m3e_enums.dart'; export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope; -export 'src/button_group_m3e_widget.dart'; +export 'src/button_group_m3e_widget.dart' + show ButtonGroupM3E, ButtonGroupM3EOverflow, ButtonGroupM3EAction; diff --git a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart index afb9322..34fb83b 100644 --- a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart +++ b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart @@ -1,16 +1,83 @@ +import 'package:button_m3e/button_m3e.dart'; import 'package:flutter/material.dart'; import '_tokens_adapter.dart'; import 'button_group_m3e_enums.dart'; import 'button_group_m3e_scope.dart'; -class ButtonGroupM3E extends StatelessWidget { +/// Controls how ButtonGroupM3E handles overflow when wrap=false. +enum ButtonGroupM3EOverflow { + /// Do not handle overflow specially (may overflow if parent allows), + /// essentially keeps the original Row/Column with mainAxisSize.min. + none, + + /// Allow scrolling along the layout axis when constraints are bounded. + scroll, + + /// Replace overflow with a trailing overflow menu button that contains + /// the remaining children. + menu, +} + +/// Style for the overflow menu when [overflow] == menu. +enum ButtonGroupM3EOverflowMenuStyle { dropdown, bottomSheet } + +/// Declarative action description used by [ButtonGroupM3E] when building its buttons. +class ButtonGroupM3EAction { + const ButtonGroupM3EAction({ + required this.label, + this.icon, + this.onPressed, + this.enabled = true, + this.style, + this.toggleable = false, + this.selected = false, + this.onSelectedChange, + this.shape, // optional override shape per action + }); + + final Widget label; + final Widget? icon; + final VoidCallback? onPressed; + final bool enabled; + final ButtonM3EStyle? style; + final bool toggleable; + final bool selected; + final ValueChanged? onSelectedChange; + final ButtonM3EShape? shape; // if null group decides + + ButtonGroupM3EAction copyWith({ + Widget? label, + Widget? icon, + VoidCallback? onPressed, + bool? enabled, + ButtonM3EStyle? style, + bool? toggleable, + bool? selected, + ValueChanged? onSelectedChange, + ButtonM3EShape? shape, + }) => + ButtonGroupM3EAction( + label: label ?? this.label, + icon: icon ?? this.icon, + onPressed: onPressed ?? this.onPressed, + enabled: enabled ?? this.enabled, + style: style ?? this.style, + toggleable: toggleable ?? this.toggleable, + selected: selected ?? this.selected, + onSelectedChange: onSelectedChange ?? this.onSelectedChange, + shape: shape ?? this.shape, + ); +} + +class ButtonGroupM3E extends StatefulWidget { const ButtonGroupM3E({ super.key, - required this.children, + this.actions = const [], this.type = ButtonGroupM3EType.standard, - this.shape = ButtonGroupM3EShape.round, - this.size = ButtonGroupM3ESize.md, + this.shape = ButtonGroupM3EShape.square, + this.size = ButtonGroupM3ESize.sm, + this.style = ButtonM3EStyle.filled, this.density = ButtonGroupM3EDensity.regular, this.direction = Axis.horizontal, this.wrap = false, @@ -25,13 +92,21 @@ class ButtonGroupM3E extends StatelessWidget { this.equalizeWidths = false, this.semanticLabel, this.clipBehavior = Clip.none, + this.overflow = ButtonGroupM3EOverflow.menu, + this.overflowIcon, + this.overflowSheetTitle, + this.selection = false, + this.selectedIndex, + this.overflowMenuStyle = ButtonGroupM3EOverflowMenuStyle.dropdown, }); - final List children; + /// Declarative actions to build buttons. Overrides [children] when not empty. + final List actions; final ButtonGroupM3EType type; final ButtonGroupM3EShape shape; final ButtonGroupM3ESize size; + final ButtonM3EStyle style; final ButtonGroupM3EDensity density; final Axis direction; @@ -50,112 +125,849 @@ class ButtonGroupM3E extends StatelessWidget { final String? semanticLabel; final Clip clipBehavior; + /// Overflow management behavior when [wrap] is false. + final ButtonGroupM3EOverflow overflow; + + /// Icon for the overflow menu trigger when [overflow] == menu. + final Widget? overflowIcon; + + /// Optional title shown at the top of the overflow sheet. + final Widget? overflowSheetTitle; + + /// Enables group selection styling behavior. + /// When true: by default inner buttons are square, outer (first & last) have round outer ends. + /// When a button is selected (via [selectedIndex] or action.selected) it becomes fully round. + final bool selection; + + /// Index of selected button when [selection] true (external control). + /// If null will use each action's [selected] flag (toggle groups). + final int? selectedIndex; + + /// How to display the overflow menu when [overflow] == menu. Defaults to dropdown. + final ButtonGroupM3EOverflowMenuStyle overflowMenuStyle; + bool get _connected => type == ButtonGroupM3EType.connected; @override - Widget build(BuildContext context) { - final tokens = metricsFor(context, size, density); - final cs = Theme.of(context).colorScheme; - final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6); - final dividerThk = - (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0); + State createState() => _ButtonGroupM3EState(); +} - final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing); - final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0; +class _ButtonGroupM3MItemMeasure { + const _ButtonGroupM3MItemMeasure(this.mainExtent); + final double mainExtent; // width for horizontal, height for vertical +} + +class _ButtonGroupM3EState extends State { + // Measurement keys for each child to derive main-axis extents. + late List _childKeys; + final GlobalKey _overflowKey = GlobalKey(); + final LayerLink _overflowLink = LayerLink(); + OverlayEntry? _overflowEntry; + + // Last measured extents for children and overflow trigger. + List<_ButtonGroupM3MItemMeasure>? _measuredChildren; + double? _measuredOverflowExtent; + double? _lastMaxMainExtent; + + List get _effectiveActions => widget.actions; + + @override + void initState() { + super.initState(); + _initKeys(); + } + + @override + void didUpdateWidget(covariant ButtonGroupM3E oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actions.length != widget.actions.length) { + _initKeys(); + _measuredChildren = null; + _measuredOverflowExtent = null; + } + } + + void _initKeys() { + _childKeys = + List.generate(_effectiveActions.length, (_) => GlobalKey()); + } + + @override + Widget build(BuildContext context) { + final tokens = metricsFor(context, widget.size, widget.density); + final cs = Theme.of(context).colorScheme; + final dividerClr = + widget.dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6); + final dividerThk = + (widget.dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0); final group = ButtonGroupM3EScope( - type: type, - shape: shape, - size: size, - density: density, - direction: direction, - isConnected: _connected, + type: widget.type, + shape: widget.shape, + size: widget.size, + density: widget.density, + direction: widget.direction, + isConnected: widget._connected, child: _buildContent( - context, effSpacing, effRunSpacing, dividerClr, dividerThk), + context, + /* spacing/run */ (widget._connected + ? 0.0 + : (widget.spacing ?? tokens.spacing)), + (widget.wrap ? (widget.runSpacing ?? tokens.runSpacing) : 0.0), + /* div */ dividerClr, + dividerThk, + ), ); final semantics = Semantics( container: true, - label: semanticLabel, + label: widget.semanticLabel, child: group, ); - if (clipBehavior == Clip.none) return semantics; + if (widget.clipBehavior == Clip.none) return semantics; return ClipRRect( - clipBehavior: clipBehavior, - borderRadius: radiusFor(context, shape, size), + clipBehavior: widget.clipBehavior, + borderRadius: radiusFor(context, widget.shape, widget.size), child: semantics, ); } - Widget _buildContent(BuildContext context, double spacing, double runSpacing, - Color dividerColor, double dividerThickness) { - if (children.isEmpty) return const SizedBox.shrink(); + Widget _buildContent( + BuildContext context, + double spacing, + double runSpacing, + Color dividerColor, + double dividerThickness, + ) { + if (_effectiveActions.isEmpty) return const SizedBox.shrink(); - if (wrap) { + if (widget.wrap) { return _wrapLayout(context, spacing, runSpacing); } - final list = []; - for (var i = 0; i < children.length; i++) { + // Overflow handling when wrap=false + switch (widget.overflow) { + case ButtonGroupM3EOverflow.none: + return _linearCore(context, spacing, dividerColor, dividerThickness); + case ButtonGroupM3EOverflow.scroll: + return _linearScrollable( + context, spacing, dividerColor, dividerThickness); + case ButtonGroupM3EOverflow.menu: + return _linearWithOverflowMenu( + context, + spacing, + runSpacing, + dividerColor, + dividerThickness, + ); + } + } + + // Original linear layout (no overflow management) + Widget _linearCore(BuildContext context, double spacing, Color dividerColor, + double dividerThickness) { + final list = _buildItemList( + context, spacing, dividerColor, dividerThickness, + count: _effectiveActions.length); + return widget.direction == Axis.horizontal + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _mapCross(widget.crossAxisAlignment), + children: list, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _mapCross(widget.crossAxisAlignment), + children: list, + ); + } + + // Scrollable variant used previously to prevent RenderFlex overflow. + Widget _linearScrollable(BuildContext context, double spacing, + Color dividerColor, double dividerThickness) { + final list = _buildItemList( + context, spacing, dividerColor, dividerThickness, + count: _effectiveActions.length); + return LayoutBuilder( + builder: (context, constraints) { + final isBounded = widget.direction == Axis.horizontal + ? constraints.hasBoundedWidth + : constraints.hasBoundedHeight; + + final core = widget.direction == Axis.horizontal + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _mapCross(widget.crossAxisAlignment), + children: list, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _mapCross(widget.crossAxisAlignment), + children: list, + ); + + if (isBounded) { + return SingleChildScrollView( + scrollDirection: widget.direction, + primary: false, + clipBehavior: Clip.hardEdge, + child: core, + ); + } + return core; + }, + ); + } + + // Overflow menu variant: shows visible children that fit, plus a trailing overflow trigger. + Widget _linearWithOverflowMenu( + BuildContext context, + double spacing, + double runSpacing, + Color dividerColor, + double dividerThickness, + ) { + return LayoutBuilder( + builder: (context, constraints) { + final maxMain = widget.direction == Axis.horizontal + ? constraints.maxWidth + : constraints.maxHeight; + if (!maxMain.isFinite) { + if (_lastMaxMainExtent != maxMain || _measuredChildren == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _lastMaxMainExtent = maxMain; + _measuredChildren = null; + _measuredOverflowExtent = null; + }); + }); + } + return _linearCore(context, spacing, dividerColor, dividerThickness); + } + // Schedule measurement pass + WidgetsBinding.instance + .addPostFrameCallback((_) => _measureChildrenMainExtents()); + + final sizes = _measuredChildren; + final fallbackChild = _defaultChildMainExtent(); + final overflowTriggerExtent = + _measuredOverflowExtent ?? _defaultOverflowExtent(); + const double eps = 0.75; // safety margin + + // Build list of child main extents + final childExtents = []; + for (var i = 0; i < _effectiveActions.length; i++) { + final e = (sizes != null && i < sizes.length) + ? sizes[i].mainExtent + : fallbackChild; + childExtents.add(e); + } + + double sepBetweenItems(int indexBefore) { + if (widget._connected) { + return widget.showDividers + ? dividerThickness + : 2.0; // gap or divider + } + return spacing; + } + + double sepBeforeOverflow(int visibleCount) { + if (visibleCount == 0) return 0; // no gap if overflow trigger first + return widget._connected + ? (widget.showDividers ? dividerThickness : 2.0) + : spacing; + } + + double widthForFirstN(int n) { + double total = 0; + for (int i = 0; i < n; i++) { + if (i > 0) total += sepBetweenItems(i - 1); + total += childExtents[i]; + } + return total; + } + + int totalCount = childExtents.length; + int visible = totalCount; + bool needsOverflow = false; + + // Try exact fit from all down to 0 + for (int n = totalCount; n >= 0; n--) { + final itemsWidth = widthForFirstN(n); + if (n == totalCount) { + if (itemsWidth <= maxMain - eps) { + visible = n; + needsOverflow = false; + break; + } + // else continue testing smaller n with overflow + } else { + final overflowGap = sepBeforeOverflow(n); + final totalNeeded = + itemsWidth + overflowGap + overflowTriggerExtent; + if (totalNeeded <= maxMain - eps) { + visible = n; + needsOverflow = true; + break; + } + } + } + + final visibleList = _buildItemList( + context, + spacing, + dividerColor, + dividerThickness, + count: visible, + ); + if (needsOverflow) { + if (visibleList.isNotEmpty) { + final gap = widget._connected + ? (widget.showDividers ? dividerThickness : 2.0) + : spacing; + if (widget._connected && widget.showDividers) { + visibleList.add(_buildDivider(dividerColor, dividerThickness)); + } else if (gap > 0) { + visibleList.add(_spacer(gap)); + } + } + visibleList.add(_buildOverflowTrigger( + context, + spacing, + dividerColor, + dividerThickness, + startIndex: visible, + )); + } + + final coreChildren = visibleList; + final core = widget.direction == Axis.horizontal + ? ClipRect( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _mapCross(widget.crossAxisAlignment), + children: coreChildren, + ), + ) + : ClipRect( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _mapCross(widget.crossAxisAlignment), + children: coreChildren, + ), + ); + + final measurer = Offstage( + offstage: true, + child: _buildMeasurer( + spacing, runSpacing, dividerColor, dividerThickness), + ); + return Stack(children: [core, measurer]); + }, + ); + } + + // Builds a hidden Wrap that lays out all children (with the same child wrappers), + // so we can measure their main-axis extents safely without overflow. + Widget _buildMeasurer(double spacing, double runSpacing, Color dividerColor, + double dividerThickness) { + final wrapped = List.generate(_effectiveActions.length, (i) { final isFirst = i == 0; - final isLast = i == children.length - 1; + final isLast = i == _effectiveActions.length - 1; + final child = _wrapItemScope( + context, + index: i, + count: _effectiveActions.length, + isFirst: isFirst, + isLast: isLast, + child: _maybeEqualized(_buildButtonForAction(i, _effectiveActions[i])), + ); + return KeyedSubtree(key: _childKeys[i], child: child); + }); + + return Directionality( + textDirection: Directionality.of(context), + child: ConstrainedBox( + constraints: const BoxConstraints(), + child: Wrap( + direction: widget.direction, + spacing: spacing, + runSpacing: runSpacing, + alignment: widget.alignment, + runAlignment: widget.runAlignment, + crossAxisAlignment: widget.crossAxisAlignment, + children: wrapped, + ), + ), + ); + } + + void _measureChildrenMainExtents() { + if (!mounted) return; + final newMeasures = <_ButtonGroupM3MItemMeasure>[]; + for (var i = 0; i < _childKeys.length; i++) { + final ctx = _childKeys[i].currentContext; + final render = ctx?.findRenderObject() as RenderBox?; + if (render != null && render.hasSize) { + final size = render.size; + final main = + widget.direction == Axis.horizontal ? size.width : size.height; + newMeasures.add(_ButtonGroupM3MItemMeasure(main)); + } else { + // fallback fill + final fallback = _defaultChildMainExtent(); + newMeasures.add(_ButtonGroupM3MItemMeasure(fallback)); + } + } + + // Measure overflow trigger extent + final octx = _overflowKey.currentContext; + final orender = octx?.findRenderObject() as RenderBox?; + double? overflowExtent; + if (orender != null && orender.hasSize) { + final sz = orender.size; + overflowExtent = + widget.direction == Axis.horizontal ? sz.width : sz.height; + } + + final changed = _measuredChildren == null || + _measuredChildren!.length != newMeasures.length || + !_listAlmostEqual(_measuredChildren!, newMeasures); + + if (changed || + (_measuredOverflowExtent == null && overflowExtent != null)) { + setState(() { + _measuredChildren = newMeasures; + if (overflowExtent != null) _measuredOverflowExtent = overflowExtent; + }); + } + } + + bool _listAlmostEqual( + List<_ButtonGroupM3MItemMeasure> a, List<_ButtonGroupM3MItemMeasure> b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if ((a[i].mainExtent - b[i].mainExtent).abs() > 0.5) return false; + } + return true; + } + + double _defaultChildMainExtent() { + // Conservative fallbacks to avoid first-frame overflow before measurement. + if (widget.direction == Axis.horizontal) { + switch (widget.size) { + case ButtonGroupM3ESize.xs: + return 56.0; + case ButtonGroupM3ESize.sm: + return 80.0; + case ButtonGroupM3ESize.md: + return 100.0; + case ButtonGroupM3ESize.lg: + return 120.0; + case ButtonGroupM3ESize.xl: + return 140.0; + } + } else { + return _buttonHeightForSize(); + } + } + + double _defaultOverflowExtent() { + // Approximate minimal icon button size along main axis (Material min is 48) + return widget.direction == Axis.horizontal ? 48.0 : _buttonHeightForSize(); + } + + double _buttonHeightForSize() { + switch (widget.size) { + case ButtonGroupM3ESize.xs: + return 28.0; + case ButtonGroupM3ESize.sm: + return 32.0; + case ButtonGroupM3ESize.md: + return 40.0; + case ButtonGroupM3ESize.lg: + return 48.0; + case ButtonGroupM3ESize.xl: + return 56.0; + } + } + + List _buildItemList( + BuildContext context, + double spacing, + Color dividerColor, + double dividerThickness, { + required int count, + }) { + final list = []; + final capped = count.clamp(0, _effectiveActions.length); + for (var i = 0; i < capped; i++) { + final isFirst = i == 0; + final isLast = i == capped - 1; + final action = _effectiveActions[i]; + + final button = + _buildButtonForAction(i, action, isFirst: isFirst, isLast: isLast); final child = _wrapItemScope( context, index: i, - count: children.length, + count: capped, isFirst: isFirst, isLast: isLast, - child: _maybeEqualized(children[i]), + child: _maybeEqualized(button), ); - list.add(child); - final isBetween = i < children.length - 1; + final isBetween = i < capped - 1; if (!isBetween) continue; - if (_connected) { - if (showDividers) { + if (widget._connected) { + if (widget.showDividers) { list.add(_buildDivider(dividerColor, dividerThickness)); + } else { + // connected small gap between items + list.add(_spacer(2.0)); } } else { list.add(_spacer(spacing)); } } + return list; + } - return direction == Axis.horizontal - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: list) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: list); + Widget _buildButtonForAction(int index, ButtonGroupM3EAction action, + {bool isFirst = false, bool isLast = false}) { + final selected = widget.selectedIndex != null + ? widget.selectedIndex == index + : action.selected; + + ButtonM3EShape shapeOut; + BorderRadius? perCorner; + if (!widget.selection) { + shapeOut = action.shape ?? _mapGroupShape(widget.shape); + } else { + if (selected) { + shapeOut = ButtonM3EShape.round; // selected always fully round + } else { + if (widget._connected) { + // Connected + selection: interior corners should have square token radius; outer ends rounded. + shapeOut = ButtonM3EShape.square; + // Obtain square base radius from button tokens for inner corners. + final squareRadiusVal = ButtonTokensAdapter(context) + .squareRadius(_mapGroupSize(widget.size)); + final innerRadius = Radius.circular(squareRadiusVal); + // Obtain round radius set for outer corners. + final roundSet = + radiusFor(context, ButtonGroupM3EShape.round, widget.size); + if (widget.direction == Axis.horizontal) { + if (isFirst) { + perCorner = BorderRadius.only( + topLeft: roundSet.topLeft, + bottomLeft: roundSet.bottomLeft, + topRight: innerRadius, + bottomRight: innerRadius, + ); + } else if (isLast) { + perCorner = BorderRadius.only( + topLeft: innerRadius, + bottomLeft: innerRadius, + topRight: roundSet.topRight, + bottomRight: roundSet.bottomRight, + ); + } else { + // Middle buttons: all corners square token radius + perCorner = BorderRadius.all(innerRadius); + } + } else { + // Vertical direction + if (isFirst) { + perCorner = BorderRadius.only( + topLeft: roundSet.topLeft, + topRight: roundSet.topRight, + bottomLeft: innerRadius, + bottomRight: innerRadius, + ); + } else if (isLast) { + perCorner = BorderRadius.only( + topLeft: innerRadius, + topRight: innerRadius, + bottomLeft: roundSet.bottomLeft, + bottomRight: roundSet.bottomRight, + ); + } else { + perCorner = BorderRadius.all(innerRadius); + } + } + } else { + // Non-connected selection: ends fully round, inner square token radius. + if (isFirst || isLast) { + shapeOut = ButtonM3EShape.round; + perCorner = null; // round shape handles corners + } else { + shapeOut = ButtonM3EShape.square; + final squareRadiusVal = ButtonTokensAdapter(context) + .squareRadius(_mapGroupSize(widget.size)); + perCorner = BorderRadius.all(Radius.circular(squareRadiusVal)); + } + } + } + } + + return ButtonM3E( + onPressed: action.onPressed, + label: action.label, + icon: action.icon, + style: action.style ?? widget.style, + size: _mapGroupSize(widget.size), + shape: shapeOut, + selected: selected, + toggleable: action.toggleable || widget.selectedIndex != null, + onSelectedChange: (val) { + action.onSelectedChange?.call(val); + if (widget.selectedIndex == null && action.onSelectedChange == null) { + setState(() { + widget.actions[index] = action.copyWith(selected: val); + }); + } else { + setState(() {}); + } + }, + enabled: action.enabled, + cornerRadiusOverride: perCorner, + ); + } + + ButtonM3ESize _mapGroupSize(ButtonGroupM3ESize s) => switch (s) { + ButtonGroupM3ESize.xs => ButtonM3ESize.xs, + ButtonGroupM3ESize.sm => ButtonM3ESize.sm, + ButtonGroupM3ESize.md => ButtonM3ESize.md, + ButtonGroupM3ESize.lg => ButtonM3ESize.lg, + ButtonGroupM3ESize.xl => ButtonM3ESize.xl, + }; + ButtonM3EShape _mapGroupShape(ButtonGroupM3EShape s) => + s == ButtonGroupM3EShape.round + ? ButtonM3EShape.round + : ButtonM3EShape.square; + + CrossAxisAlignment _mapCross(WrapCrossAlignment w) { + switch (w) { + case WrapCrossAlignment.start: + return CrossAxisAlignment.start; + case WrapCrossAlignment.end: + return CrossAxisAlignment.end; + case WrapCrossAlignment.center: + return CrossAxisAlignment.center; + } + } + + Widget _buildOverflowTrigger( + BuildContext context, + double spacing, + Color dividerColor, + double dividerThickness, { + required int startIndex, + }) { + final icon = widget.overflowIcon ?? const Icon(Icons.more_horiz); + + // Build trigger as a ButtonM3E to look like other buttons. + final triggerButton = ButtonM3E( + key: _overflowKey, + onPressed: () => _showOverflow(context, startIndex), + label: icon, + style: widget.style, + size: _mapGroupSize(widget.size), + shape: _mapGroupShape(widget.shape), + enabled: true, + ); + + final target = CompositedTransformTarget( + link: _overflowLink, + child: ConstrainedBox( + constraints: widget.direction == Axis.horizontal + ? const BoxConstraints(minWidth: 40) + : BoxConstraints(minHeight: _buttonHeightForSize()), + child: triggerButton, + ), + ); + + return ButtonGroupM3EItemScope( + index: startIndex, + count: _effectiveActions.length, + isFirst: startIndex == 0, + isLast: startIndex == _effectiveActions.length - 1, + child: target, + ); + } + + Future _showOverflow(BuildContext context, int startIndex) async { + switch (widget.overflowMenuStyle) { + case ButtonGroupM3EOverflowMenuStyle.dropdown: + return _showOverflowDropdown(context, startIndex); + case ButtonGroupM3EOverflowMenuStyle.bottomSheet: + return _showOverflowBottomSheet(context, startIndex); + } + } + + Future _showOverflowDropdown( + BuildContext context, int startIndex) async { + // Build actions that close the dropdown before invoking onPressed + final actions = []; + for (var i = startIndex; i < _effectiveActions.length; i++) { + final a = _effectiveActions[i]; + actions.add(a.copyWith(onPressed: () { + _removeOverflowEntry(); + a.onPressed?.call(); + })); + } + if (actions.isEmpty) return; + + // Remove any existing entry + _removeOverflowEntry(); + + final overlay = Overlay.of(context, rootOverlay: true); + if (overlay == null) return; + + _overflowEntry = OverlayEntry( + builder: (ctx) { + return Stack( + children: [ + // Tapping outside dismisses the dropdown + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _removeOverflowEntry, + ), + ), + CompositedTransformFollower( + link: _overflowLink, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + offset: const Offset(0, 4), // small vertical gap + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: IntrinsicWidth( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 6, + ), + child: ButtonGroupM3E( + type: widget.type, + shape: widget.shape, + size: widget.size, + style: widget.style, + density: widget.density, + direction: Axis.vertical, + wrap: false, + spacing: 6, + alignment: WrapAlignment.start, + crossAxisAlignment: + WrapCrossAlignment.end, // right align buttons + showDividers: false, + equalizeWidths: true, + clipBehavior: Clip.none, + overflow: ButtonGroupM3EOverflow.none, + selection: false, + actions: actions, + ), + ), + ), + ), + ), + ], + ); + }, + ); + + overlay.insert(_overflowEntry!); + } + + Future _showOverflowBottomSheet( + BuildContext context, int startIndex) async { + final overflowActions = []; + for (var i = startIndex; i < _effectiveActions.length; i++) { + overflowActions.add(_effectiveActions[i]); + } + + if (overflowActions.isEmpty) return; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.overflowSheetTitle != null) ...[ + DefaultTextStyle( + style: Theme.of(context).textTheme.titleMedium!, + child: widget.overflowSheetTitle!, + ), + const SizedBox(height: 12), + ], + ButtonGroupM3E( + type: widget.type, + shape: widget.shape, + size: widget.size, + density: widget.density, + direction: widget.direction, + wrap: true, + spacing: widget.spacing, + runSpacing: widget.runSpacing, + alignment: widget.alignment, + runAlignment: widget.runAlignment, + crossAxisAlignment: widget.crossAxisAlignment, + showDividers: widget.showDividers, + dividerColor: widget.dividerColor, + dividerThickness: widget.dividerThickness, + equalizeWidths: widget.equalizeWidths, + clipBehavior: widget.clipBehavior, + overflow: ButtonGroupM3EOverflow.none, + selection: widget.selection, + selectedIndex: widget.selectedIndex, + actions: overflowActions, + ), + ], + ), + ), + ); + }, + ); } Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) { - final wrapped = List.generate(children.length, (i) { + final wrapped = List.generate(_effectiveActions.length, (i) { final isFirst = i == 0; - final isLast = i == children.length - 1; + final isLast = i == _effectiveActions.length - 1; return _wrapItemScope( context, index: i, - count: children.length, + count: _effectiveActions.length, isFirst: isFirst, isLast: isLast, - child: _maybeEqualized(children[i]), + child: _maybeEqualized(_buildButtonForAction(i, _effectiveActions[i])), ); }); return Wrap( - direction: direction, + direction: widget.direction, spacing: spacing, runSpacing: runSpacing, - alignment: alignment, - runAlignment: runAlignment, - crossAxisAlignment: crossAxisAlignment, + alignment: widget.alignment, + runAlignment: widget.runAlignment, + crossAxisAlignment: widget.crossAxisAlignment, children: wrapped, ); } @@ -175,19 +987,30 @@ class ButtonGroupM3E extends StatelessWidget { ); } - Widget _spacer(double spacing) => direction == Axis.horizontal + @override + void dispose() { + _removeOverflowEntry(); + super.dispose(); + } + + void _removeOverflowEntry() { + _overflowEntry?.remove(); + _overflowEntry = null; + } + + Widget _spacer(double spacing) => widget.direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing); Widget _buildDivider(Color color, double thickness) { - return direction == Axis.horizontal + return widget.direction == Axis.horizontal ? Container(width: thickness, height: 24, color: color) : Container(height: thickness, width: 24, color: color); } Widget _maybeEqualized(Widget child) { - if (!equalizeWidths) return child; - final minW = switch (size) { + if (!widget.equalizeWidths) return child; + final minW = switch (widget.size) { ButtonGroupM3ESize.xs => 40.0, ButtonGroupM3ESize.sm => 56.0, ButtonGroupM3ESize.md => 72.0, diff --git a/packages/button_group_m3e/pubspec.yaml b/packages/button_group_m3e/pubspec.yaml index 18bb7d4..364783e 100644 --- a/packages/button_group_m3e/pubspec.yaml +++ b/packages/button_group_m3e/pubspec.yaml @@ -1,6 +1,6 @@ name: button_group_m3e description: Wrapper-only Button Group for Material 3 Expressive (layout, shape, size propagation). -version: 0.1.1 +version: 0.3.0 repository: https://github.com/EmilyMoonstone/material_3_expressive/tree/main/packages/button_group_m3e issue_tracker: https://github.com/EmilyMonestone/material_3_expressive/issues @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter m3e_design: ^0.2.1 + button_m3e: ^0.1.2 dev_dependencies: flutter_test: diff --git a/packages/button_group_m3e/pubspec_overrides.yaml b/packages/button_group_m3e/pubspec_overrides.yaml index 69acba6..6c4dda6 100644 --- a/packages/button_group_m3e/pubspec_overrides.yaml +++ b/packages/button_group_m3e/pubspec_overrides.yaml @@ -2,3 +2,5 @@ dependency_overrides: m3e_design: path: ..\\m3e_design + button_m3e: + path: ..\\button_m3e diff --git a/packages/button_group_m3e/test/button_group_m3e_overflow_test.dart b/packages/button_group_m3e/test/button_group_m3e_overflow_test.dart new file mode 100644 index 0000000..0f33923 --- /dev/null +++ b/packages/button_group_m3e/test/button_group_m3e_overflow_test.dart @@ -0,0 +1,246 @@ +import 'package:button_group_m3e/button_group_m3e.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('does not overflow horizontally under bounded width by scrolling', + (tester) async { + final actions = List.generate( + 8, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 120, height: 40, child: Text('A$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: + 240, // force bounded width smaller than total children width + child: ButtonGroupM3E( + actions: actions, + // wrap=false by default + overflow: ButtonGroupM3EOverflow.scroll, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull, reason: 'Should not overflow'); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + + testWidgets('wrap=true uses Wrap and avoids overflow without scroll', + (tester) async { + final actions = List.generate( + 8, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 120, height: 40, child: Text('B$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 240, + child: ButtonGroupM3E( + wrap: true, + runSpacing: 4, + spacing: 4, + actions: actions, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull, + reason: 'Wrap should avoid overflow'); + expect(find.byType(Wrap), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsNothing); + }); + + testWidgets('vertical direction scrolls when height is bounded', + (tester) async { + final actions = List.generate( + 8, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 100, height: 60, child: Text('C$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + height: + 160, // bounded height smaller than sum of children heights + child: ButtonGroupM3E( + direction: Axis.vertical, + actions: actions, + overflow: ButtonGroupM3EOverflow.scroll, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + + testWidgets('scroll overflow prevents RenderFlex overflow', (tester) async { + final actions = List.generate( + 8, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 120, height: 40, child: Text('D$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 240, + child: ButtonGroupM3E( + actions: actions, + overflow: ButtonGroupM3EOverflow.scroll, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + + testWidgets('wrap=true uses Wrap and avoids scroll', (tester) async { + final actions = List.generate( + 8, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 120, height: 40, child: Text('E$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 240, + child: ButtonGroupM3E( + wrap: true, + runSpacing: 4, + spacing: 4, + actions: actions, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(Wrap), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsNothing); + }); + + testWidgets( + 'menu overflow shows trigger and opens sheet with remaining children', + (tester) async { + final actions = List.generate( + 6, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 100, height: 40, child: Text('M$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 250, // Should not fit all 6 at ~100 each + child: ButtonGroupM3E( + actions: actions, + overflow: ButtonGroupM3EOverflow.menu, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + // Overflow trigger should appear (IconButton default Icons.more_horiz) + expect(find.byIcon(Icons.more_horiz), findsOneWidget); + + // Tap overflow trigger + await tester.tap(find.byIcon(Icons.more_horiz)); + await tester.pumpAndSettle(); + + // Bottom sheet should appear + expect(find.byType(BottomSheet), findsOneWidget); + // All items should be accessible either inline or in sheet (texts M0..M5) + for (var i = 0; i < actions.length; i++) { + expect(find.text('M$i'), findsWidgets); + } + }); + + testWidgets('menu overflow does not appear if all children fit', + (tester) async { + final actions = List.generate( + 3, + (i) => ButtonGroupM3EAction( + label: SizedBox(width: 60, height: 40, child: Text('F$i')), + onPressed: () {}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 400, // Enough for all + child: ButtonGroupM3E( + actions: actions, + overflow: ButtonGroupM3EOverflow.menu, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.more_horiz), findsNothing); + }); +} diff --git a/packages/button_m3e/CHANGELOG.md b/packages/button_m3e/CHANGELOG.md index d780806..d348ef2 100644 --- a/packages/button_m3e/CHANGELOG.md +++ b/packages/button_m3e/CHANGELOG.md @@ -1,2 +1,7 @@ +## 0.1.2 +- Add optional `cornerRadiusOverride` to allow per-corner radii when using square shapes (enables grouped buttons with rounded outer and square inner corners). +- Shape resolution updated to honor `cornerRadiusOverride` for square/pressed-square states; no breaking API changes. +- Internal: minor style resolution refactors, no behavior changes for existing usages. + ## 0.1.0 - Changelog initialized. diff --git a/packages/button_m3e/lib/src/button_m3e.dart b/packages/button_m3e/lib/src/button_m3e.dart index 8b5f5b2..649bf62 100644 --- a/packages/button_m3e/lib/src/button_m3e.dart +++ b/packages/button_m3e/lib/src/button_m3e.dart @@ -19,6 +19,7 @@ class ButtonM3E extends StatefulWidget { this.smallPaddingDeprecated24 = false, this.enabled = true, this.statesController, + this.cornerRadiusOverride, // new optional per-corner override for square shapes }); final VoidCallback? onPressed; @@ -33,6 +34,7 @@ class ButtonM3E extends StatefulWidget { final bool smallPaddingDeprecated24; final bool enabled; final WidgetStatesController? statesController; + final BorderRadius? cornerRadiusOverride; @override State createState() => _ButtonM3EState(); @@ -165,12 +167,17 @@ class _ButtonM3EState extends State { final selected = states.contains(WidgetState.selected) || widget.selected; final pressed = states.contains(WidgetState.pressed); + final BorderRadius squareBaseRadius = widget.cornerRadiusOverride ?? + BorderRadius.circular(tokens.squareRadius(widget.size)); + final BorderRadius pressedSquareRadius = widget.cornerRadiusOverride ?? + BorderRadius.circular(tokens.pressedRadius(widget.size)); + OutlinedBorder round = const StadiumBorder(); OutlinedBorder square = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(tokens.squareRadius(widget.size)), + borderRadius: squareBaseRadius, ); OutlinedBorder pressedSquare = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(tokens.pressedRadius(widget.size)), + borderRadius: pressedSquareRadius, ); OutlinedBorder base = widget.shape == ButtonM3EShape.round ? round : square; diff --git a/packages/button_m3e/pubspec.yaml b/packages/button_m3e/pubspec.yaml index 013df3c..1d79652 100644 --- a/packages/button_m3e/pubspec.yaml +++ b/packages/button_m3e/pubspec.yaml @@ -1,7 +1,6 @@ - name: button_m3e description: Material 3 Expressive Buttons for Flutter with 5 styles, 5 sizes, round/square shapes, and toggle selection. -version: 0.1.1 +version: 0.1.2 repository: https://github.com/EmilyMoonstone/material_3_expressive/tree/main/packages/button_m3e issue_tracker: https://github.com/EmilyMonestone/material_3_expressive/issues diff --git a/packages/m3e_collection/pubspec.yaml b/packages/m3e_collection/pubspec.yaml index f44bc56..c5e2947 100644 --- a/packages/m3e_collection/pubspec.yaml +++ b/packages/m3e_collection/pubspec.yaml @@ -1,6 +1,6 @@ name: m3e_collection description: Aggregated exports of all Material 3 Expressive components for Flutter. -version: 0.3.5 +version: 0.3.6 repository: https://github.com/EmilyMoonstone/material_3_expressive/tree/main/packages/m3e_collection issue_tracker: https://github.com/EmilyMonestone/material_3_expressive/issues @@ -9,8 +9,8 @@ environment: dependencies: app_bar_m3e: ^0.1.2 - button_group_m3e: ^0.1.1 - button_m3e: ^0.1.1 + button_group_m3e: ^0.3.0 + button_m3e: ^0.1.2 expressive_refresh: ^0.1.2 fab_m3e: ^0.1.1 flutter: diff --git a/widgetbook/lib/button_group_m3e/button_group_m3e_usecases.dart b/widgetbook/lib/button_group_m3e/button_group_m3e_usecases.dart index 7605399..e646953 100644 --- a/widgetbook/lib/button_group_m3e/button_group_m3e_usecases.dart +++ b/widgetbook/lib/button_group_m3e/button_group_m3e_usecases.dart @@ -190,15 +190,25 @@ Widget buildButtonGroupM3EDefaultUseCase(BuildContext context) { labelBuilder: (String v) => v, ); - late final List children; + late final List actions; switch (contentMode) { case 'with_icon': - children = _demoButtonsWithIcons(context); + actions = _demoButtonsWithIcons(context) + .map((b) => ButtonGroupM3EAction( + label: (b as ButtonM3E).label, + icon: b.icon, + onPressed: b.onPressed, + style: b.style)) + .toList(); break; default: - children = _demoButtonsWithLabels(context); + actions = _demoButtonsWithLabels(context) + .map((b) => ButtonGroupM3EAction( + label: (b as ButtonM3E).label, + onPressed: b.onPressed, + style: b.style)) + .toList(); } - return Center( child: ButtonGroupM3E( type: type, @@ -218,7 +228,7 @@ Widget buildButtonGroupM3EDefaultUseCase(BuildContext context) { equalizeWidths: equalizeWidths, semanticLabel: semanticLabel, clipBehavior: clip, - children: children, + actions: actions, ), ); } @@ -236,7 +246,12 @@ Widget buildButtonGroupM3EConnectedUseCase(BuildContext context) { label: 'equalizeWidths', initialValue: false, ), - children: _demoButtonsWithLabels(context), + actions: _demoButtonsWithLabels(context) + .map((b) => ButtonGroupM3EAction( + label: (b as ButtonM3E).label, + onPressed: b.onPressed, + style: b.style)) + .toList(), ), ); } @@ -257,7 +272,12 @@ Widget buildButtonGroupM3EVerticalUseCase(BuildContext context) { max: 32, divisions: 32, ), - children: _demoButtonsWithLabels(context), + actions: _demoButtonsWithLabels(context) + .map((b) => ButtonGroupM3EAction( + label: (b as ButtonM3E).label, + onPressed: b.onPressed, + style: b.style)) + .toList(), ), ); } @@ -271,13 +291,17 @@ Widget buildButtonGroupM3EWrappedManyItemsUseCase(BuildContext context) { max: 40, divisions: 40, ); - final List items = List.generate(count, (int i) { - return OutlinedButton( - onPressed: () => debugPrint('Pressed: Item #$i'), - child: Text('Item $i'), - ); - }); - + final List actions = + List.generate( + count, + (int i) { + return ButtonGroupM3EAction( + label: Text('Item $i'), + onPressed: () => debugPrint('Pressed: Item #$i'), + style: ButtonM3EStyle.outlined, + ); + }, + ); return Center( child: SizedBox( width: 360, @@ -305,7 +329,7 @@ Widget buildButtonGroupM3EWrappedManyItemsUseCase(BuildContext context) { alignment: _knobWrapAlignment(context, label: 'alignment'), runAlignment: _knobWrapAlignment(context, label: 'runAlignment'), crossAxisAlignment: _knobCrossAlignment(context), - children: items, + actions: actions, ), ), ); @@ -313,21 +337,20 @@ Widget buildButtonGroupM3EWrappedManyItemsUseCase(BuildContext context) { @UseCase(name: 'equalized_long_text', type: ButtonGroupM3E) Widget buildButtonGroupM3EEqualizedLongTextUseCase(BuildContext context) { - final List children = [ - ElevatedButton( - onPressed: () => debugPrint('Pressed: Very long primary label'), - child: const Text('Very long primary label'), - ), - OutlinedButton( - onPressed: () => debugPrint('Pressed: Short'), - child: const Text('Short'), - ), - TextButton( - onPressed: () => debugPrint('Pressed: Mid length'), - child: const Text('Mid length'), - ), + final actions = [ + ButtonGroupM3EAction( + label: const Text('Very long primary label'), + onPressed: () => debugPrint('Pressed: Very long primary label'), + style: ButtonM3EStyle.filled), + ButtonGroupM3EAction( + label: const Text('Short'), + onPressed: () => debugPrint('Pressed: Short'), + style: ButtonM3EStyle.outlined), + ButtonGroupM3EAction( + label: const Text('Mid length'), + onPressed: () => debugPrint('Pressed: Mid length'), + style: ButtonM3EStyle.text), ]; - return Center( child: ButtonGroupM3E( type: _knobType(context), @@ -335,17 +358,14 @@ Widget buildButtonGroupM3EEqualizedLongTextUseCase(BuildContext context) { size: _knobSize(context), density: _knobDensity(context), equalizeWidths: true, - children: children, + actions: actions, ), ); } @UseCase(name: 'empty', type: ButtonGroupM3E) Widget buildButtonGroupM3EEmptyUseCase(BuildContext context) { - // Boundary: no children → should layout to zero size. - return const Center( - child: ButtonGroupM3E(children: []), - ); + return const Center(child: ButtonGroupM3E(actions: [])); } @UseCase(name: 'with_icon', type: ButtonGroupM3E) @@ -357,7 +377,13 @@ Widget buildButtonGroupM3EWithIconUseCase(BuildContext context) { size: _knobSize(context), density: _knobDensity(context), direction: _knobDirection(context), - children: _demoButtonsWithIcons(context), + actions: _demoButtonsWithIcons(context) + .map((b) => ButtonGroupM3EAction( + label: (b as ButtonM3E).label, + icon: b.icon, + onPressed: b.onPressed, + style: b.style)) + .toList(), ), ); }