Add ButtonGroupM3E component with overflow handling and update API to use actions

This commit is contained in:
Emily Pauli 2025-11-12 12:51:29 +01:00
commit 582af894f3
14 changed files with 1463 additions and 255 deletions

View file

@ -1,2 +1,31 @@
## 0.3.0
- Breaking: Removed legacy `children` parameter. Use `actions: List<ButtonGroupM3EAction>` 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.

View file

@ -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 (XSXL), 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<ButtonGroupM3EAction>`.
```dart
class ButtonGroupM3E extends StatelessWidget {
const ButtonGroupM3E({
required List<Widget> 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<bool>? 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://<your-github-username>.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<Widget> — 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.

View file

@ -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;

View file

@ -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:

View file

@ -2,3 +2,5 @@
dependency_overrides:
m3e_design:
path: ..\\m3e_design
button_m3e:
path: ..\\button_m3e

View file

@ -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<ButtonGroupM3EAction>.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<ButtonGroupM3EAction>.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<ButtonGroupM3EAction>.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<ButtonGroupM3EAction>.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<ButtonGroupM3EAction>.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<ButtonGroupM3EAction>.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<ButtonGroupM3EAction>.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);
});
}

View file

@ -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.

View file

@ -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<ButtonM3E> createState() => _ButtonM3EState();
@ -165,12 +167,17 @@ class _ButtonM3EState extends State<ButtonM3E> {
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;

View file

@ -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

View file

@ -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: