Add initial configuration, tokens, and widgets for M3E components

- Introduced `.gitignore` and `.metadata` for apps and examples.
- Added Flutter/Dart analysis configurations (`analysis_options.yaml`).
- Implemented foundational tokens and themes for M3E (colors, shapes).
- Created base implementations for `IconButtonM3E` and `SplitButtonM3E`.
- Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
Emily Pauli 2025-10-21 22:15:15 +02:00
commit 62ecb86b76
184 changed files with 9872 additions and 0 deletions

31
packages/split_button_m3e/.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/

View file

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
channel: "stable"
project_type: package

View file

@ -0,0 +1,7 @@
## 0.1.0
- Initial release: SplitButtonM3E (Material 3 Expressive)
- Sizes XSXL, variants filled/tonal/outlined/elevated
- A11y: 48×48 min tap targets for each segment
- Menu via MenuAnchor, caret rotation, keyboard support
- Example app + tests

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,133 @@
# split_button_m3e
Material 3 Expressive Split Button for Flutter. Two-segment control:
- Leading: primary action (icon, label, or both)
- Trailing: menu trigger (chevron)
All sizes, paddings, radii, and offsets are token-driven, aligned to measurement boards.
## Quick start
```dart
import 'package:split_button_m3e/split_button_m3e.dart';
SplitButtonM3E<String>(
size: SplitButtonM3ESize.md,
shape: SplitButtonM3EShape.round,
emphasis: SplitButtonM3EEmphasis.tonal,
label: 'Save',
leadingIcon: Icons.save_outlined,
onPressed: () => debugPrint('Primary pressed'),
items: const [
SplitButtonM3EItem<String>(value: 'draft', child: 'Save as draft'),
SplitButtonM3EItem<String>(value: 'close', child: 'Save & close'),
],
onSelected: (v) => debugPrint('Selected: $v'),
// Optional tooltips help with semantics and tests
leadingTooltip: 'Save',
trailingTooltip: 'Open menu',
);
```
## Behavior and layout
- Two segments with a fixed inner gap of 2dp.
- Trailing chevron rotates 180° when the menu is open.
- Menu opens aligned to the trailing edge of the arrow button (right edge in LTR, left in RTL).
- Optical chevron offset is applied only in the unselected (closed) state for asymmetrical layout.
- Pressed/expanded shape morph follows the expressive M3 pattern:
- MD/LG/XL: when shape = round and arrow is pressed or menu is open, the trailing segment morphs into a perfect circle (diameter = control height), no inner padding, no optical offset.
- XS/SM: no circle morph in selected state. The selected trailing segment uses a fixed total width of 48dp with side paddings of 13dp.
- Each segment maintains a minimum touch target of 48dp.
## Tokens (by size)
Heights
- XS 32 · S 40 · M 56 · L 96 · XL 136
Trailing width (centered chevron)
- XS 22 · S 22 · M 26 · L 38 · XL 50
Inner gap (between segments)
- 2dp
Inner corner radius (facing edges)
- XS 4 · S 4 · M 4 · L 8 · XL 12
Icon sizes
- XS 20 · S 24 · M 24 · L 32 · XL 40
Optical chevron offset (unselected/resting)
- XS 1 · S 1 · M 2 · L 3 · XL 6
Asymmetrical (unselected) paddings and blocks
- XS: leadingIconBlock 20, leftOuter 12, gap icon→label 4, labelRight 10, trailingLeftInner 12, rightOuter 14
- S: leadingIconBlock 20, leftOuter 16, gap 8, labelRight 12, trailingLeftInner 12, rightOuter 14
- M: leadingIconBlock 24, leftOuter 24, gap 8, labelRight 24, trailingLeftInner 13, rightOuter 17
- L: leadingIconBlock 32, leftOuter 48, gap 12, labelRight 48, trailingLeftInner 26, rightOuter 32
- XL: leadingIconBlock 40, leftOuter 64, gap 16, labelRight 64, trailingLeftInner 37, rightOuter 49
Symmetrical (selected) trailing segment
- Trailing width (centered chevron) + side padding ×2
- Side padding per size: XS 13 · S 13 · M 15 · L 29 · XL 43
- Special case: XS/SM selected total width is 48 (22 + 13 + 13) with 13dp side padding; no full rounding.
Pressed morph radii
- Per-size pressed radius tokens are applied to the pressed segment; when round and MD/LG/XL, trailing becomes a circle while pressed/open.
## API summary
Props
- size: SplitButtonM3ESize (xs, sm, md, lg, xl)
- shape: SplitButtonM3EShape (round, square)
- emphasis: SplitButtonM3EEmphasis (filled, tonal, elevated, outlined, text)
- label: String? (leading segment)
- leadingIcon: IconData? (leading segment icon)
- onPressed: VoidCallback? (leading action)
- items: List<SplitButtonM3EItem<T>>? (trailing menu items), or
- menuBuilder: List<PopupMenuEntry<T>> Function(BuildContext)?
- onSelected: ValueChanged<T>? (when an item is chosen)
- trailingAlignment: SplitButtonM3ETrailingAlignment (opticalCenter, geometricCenter)
- leadingTooltip, trailingTooltip: String? (for semantics/UX)
- enabled: bool (default true)
Items
```dart
const SplitButtonM3EItem<T>({
required T value,
required Object child, // plain string or Widget
bool enabled = true,
});
```
## Accessibility
- Each segment is independently focusable; minimum 48×48dp hit target.
- Tooltips provide accessible names; you can supply copy like “Open menu”.
- Chevron rotation and selected state are conveyed via menu open/close.
## Example (menuBuilder)
```dart
SplitButtonM3E<int>(
size: SplitButtonM3ESize.md,
shape: SplitButtonM3EShape.square,
label: 'More',
leadingIcon: Icons.more_horiz,
onPressed: () => debugPrint('Primary'),
menuBuilder: (context) => [
const PopupMenuItem<int>(value: 1, child: Text('Option 1')),
const PopupMenuItem<int>(value: 2, child: Text('Option 2')),
],
onSelected: (v) => debugPrint('Picked $v'),
trailingAlignment: SplitButtonM3ETrailingAlignment.opticalCenter,
);
```
## Notes
- Menu aligns to the trailing arrows edge (LTR right, RTL left).
- Optical centering is applied only when the menu is closed (unselected asymmetrical state).
- When shape=round and size is MD/LG/XL, the trailing segment becomes a perfect circle while pressed/open; XS/SM remain rectangular with the selected geometry (48 total width, 13 side padding).
## License
MIT

View file

@ -0,0 +1,5 @@
# include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_single_quotes: true

View file

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View file

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: android
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: ios
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: linux
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: macos
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: web
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: windows
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View file

@ -0,0 +1,16 @@
# split_button_m3e_example
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
# include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:split_button_m3e/split_button_m3e.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'SplitButtonM3E Demo',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const DemoHome(),
);
}
}
class DemoHome extends StatelessWidget {
const DemoHome({super.key});
void _snack(BuildContext ctx, String msg) {
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar(content: Text(msg)));
}
@override
Widget build(BuildContext context) {
const sizes = SplitButtonM3ESize.values;
const variants = SplitButtonM3EEmphasis.values;
return Scaffold(
appBar: AppBar(title: const Text('SplitButtonM3E Demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('Basic usage',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
SplitButtonM3E<String>(
label: 'Save',
leadingIcon: Icons.save_outlined,
onPressed: () => _snack(context, 'Save pressed'),
items: const [
SplitButtonM3EItem<String>(
value: 'draft', child: 'Save as draft'),
SplitButtonM3EItem<String>(value: 'close', child: 'Save & close'),
],
onSelected: (v) => _snack(context, 'Selected: $v'),
),
const SizedBox(height: 24),
const Text('Variants × Sizes (round)',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
for (final v in variants) ...[
Text(v.toString().split('.').last.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final s in sizes)
SplitButtonM3E<String>(
label: 'Action',
leadingIcon: Icons.play_arrow,
onPressed: () => _snack(context, 'Primary: $v/$s'),
items: const [
SplitButtonM3EItem<String>(value: 'alt1', child: 'Alt 1'),
SplitButtonM3EItem<String>(value: 'alt2', child: 'Alt 2'),
],
onSelected: (v) => _snack(context, 'Selected: $v ($s)'),
emphasis: v,
size: s,
shape: SplitButtonM3EShape.round,
),
],
),
const SizedBox(height: 16),
],
const SizedBox(height: 24),
const Text('Square shape',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final v in variants)
SplitButtonM3E<String>(
label: 'Share',
leadingIcon: Icons.share,
onPressed: () => _snack(context, 'Share primary'),
items: const [
SplitButtonM3EItem<String>(
value: 'link', child: 'Copy link'),
SplitButtonM3EItem<String>(value: 'email', child: 'Email'),
],
onSelected: (v) => _snack(context, 'Selected: $v'),
emphasis: v,
size: SplitButtonM3ESize.md,
shape: SplitButtonM3EShape.square,
),
],
),
const SizedBox(height: 32),
],
),
);
}
}

View file

@ -0,0 +1,21 @@
name: split_button_m3e_example
description: Example for split_button_m3e
publish_to: "none"
environment:
sdk: ">=3.2.0 <4.0.0"
flutter: ">=3.19.0"
dependencies:
flutter:
sdk: flutter
split_button_m3e:
path: ../
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

View file

@ -0,0 +1,5 @@
library split_button_m3e;
export 'src/enums.dart';
export 'src/menu_items.dart';
export 'src/split_button.dart';

View file

@ -0,0 +1,160 @@
part of 'enums.dart';
/// Token bucket for SplitButtonM3E (dp, ms, turns, etc.).
class SplitButtonM3ETokens {
const SplitButtonM3ETokens._();
// Control heights per size
static const Map<SplitButtonM3ESize, double> height = {
SplitButtonM3ESize.xs: 32,
SplitButtonM3ESize.sm: 40,
SplitButtonM3ESize.md: 56,
SplitButtonM3ESize.lg: 96,
SplitButtonM3ESize.xl: 136,
};
// Trailing segment width
static const Map<SplitButtonM3ESize, double> trailingSegmentWidth = {
SplitButtonM3ESize.xs: 22,
SplitButtonM3ESize.sm: 22,
SplitButtonM3ESize.md: 26,
SplitButtonM3ESize.lg: 38,
SplitButtonM3ESize.xl: 50,
};
// Inner gap between segments
static const double innerGap = 2.0;
// Inner corner radius (both facing edges)
static const Map<SplitButtonM3ESize, double> innerCornerRadius = {
SplitButtonM3ESize.xs: 4,
SplitButtonM3ESize.sm: 4,
SplitButtonM3ESize.md: 4,
SplitButtonM3ESize.lg: 8,
SplitButtonM3ESize.xl: 12,
};
// Inner padding (from inner edge to content)
static const Map<SplitButtonM3ESize, double> innerPadding = {
SplitButtonM3ESize.xs: 4,
SplitButtonM3ESize.sm: 4,
SplitButtonM3ESize.md: 4,
SplitButtonM3ESize.lg: 8,
SplitButtonM3ESize.xl: 12,
};
// Menu chevron optical offset (unselected/resting; negative = shift left)
static const Map<SplitButtonM3ESize, double> menuIconOffsetUnselected = {
SplitButtonM3ESize.xs: -1,
SplitButtonM3ESize.sm: -1,
SplitButtonM3ESize.md: -2,
SplitButtonM3ESize.lg: -3,
SplitButtonM3ESize.xl: -6,
};
// Icon glyph size (for both segments)
static const Map<SplitButtonM3ESize, double> icon = {
SplitButtonM3ESize.xs: 20.0,
SplitButtonM3ESize.sm: 24.0,
SplitButtonM3ESize.md: 24.0,
SplitButtonM3ESize.lg: 32.0,
SplitButtonM3ESize.xl: 40.0,
};
// Minimum touch target per segment
static const double minTapTarget = 48.0;
// Shape radii (outer corners) and pressed morph
// round = half height; square 25% height; pressed 20% height
static const Map<SplitButtonM3ESize, double> outerRadiusRound = {
SplitButtonM3ESize.xs: 16,
SplitButtonM3ESize.sm: 20,
SplitButtonM3ESize.md: 28,
SplitButtonM3ESize.lg: 48,
SplitButtonM3ESize.xl: 68,
};
static const Map<SplitButtonM3ESize, double> outerRadiusSquare = {
SplitButtonM3ESize.xs: 8,
SplitButtonM3ESize.sm: 10,
SplitButtonM3ESize.md: 14,
SplitButtonM3ESize.lg: 24,
SplitButtonM3ESize.xl: 34,
};
static const Map<SplitButtonM3ESize, double> pressedRadius = {
SplitButtonM3ESize.xs: 6,
SplitButtonM3ESize.sm: 8,
SplitButtonM3ESize.md: 11,
SplitButtonM3ESize.lg: 19,
SplitButtonM3ESize.xl: 27,
};
// Layout: Asymmetrical (optically centered trailing; unselected)
static const Map<SplitButtonM3ESize, double> leadingIconBlockWidth = {
SplitButtonM3ESize.xs: 20,
SplitButtonM3ESize.sm: 20,
SplitButtonM3ESize.md: 24,
SplitButtonM3ESize.lg: 32,
SplitButtonM3ESize.xl: 40,
};
static const Map<SplitButtonM3ESize, double> leftOuterPadding = {
SplitButtonM3ESize.xs: 12,
SplitButtonM3ESize.sm: 16,
SplitButtonM3ESize.md: 24,
SplitButtonM3ESize.lg: 48,
SplitButtonM3ESize.xl: 64,
};
static const Map<SplitButtonM3ESize, double> gapIconToLabel = {
SplitButtonM3ESize.xs: 4,
SplitButtonM3ESize.sm: 8,
SplitButtonM3ESize.md: 8,
SplitButtonM3ESize.lg: 12,
SplitButtonM3ESize.xl: 16,
};
static const Map<SplitButtonM3ESize, double> labelRightPaddingBeforeDivider =
{
SplitButtonM3ESize.xs: 10,
SplitButtonM3ESize.sm: 12,
SplitButtonM3ESize.md: 24,
SplitButtonM3ESize.lg: 48,
SplitButtonM3ESize.xl: 64,
};
static const Map<SplitButtonM3ESize, double> trailingLeftInnerPadding = {
SplitButtonM3ESize.xs: 12,
SplitButtonM3ESize.sm: 12,
SplitButtonM3ESize.md: 13,
SplitButtonM3ESize.lg: 26,
SplitButtonM3ESize.xl: 37,
};
static const Map<SplitButtonM3ESize, double> rightOuterPadding = {
SplitButtonM3ESize.xs: 14,
SplitButtonM3ESize.sm: 14,
SplitButtonM3ESize.md: 17,
SplitButtonM3ESize.lg: 32,
SplitButtonM3ESize.xl: 49,
};
// Layout: Symmetrical (trailing centered; selected)
static const Map<SplitButtonM3ESize, double> sidePaddingSelected = {
SplitButtonM3ESize.xs: 13,
SplitButtonM3ESize.sm: 13,
SplitButtonM3ESize.md: 15,
SplitButtonM3ESize.lg: 29,
SplitButtonM3ESize.xl: 43,
};
// Animation
static const Duration morphDuration = Duration(milliseconds: 120);
static const Curve morphCurve = Curves.easeOut;
static const double chevronOpenTurns = 0.5; // 180°
static const Duration chevronDuration = Duration(milliseconds: 120);
// Focus ring
static const double focusStrokeWidth = 2.0;
}

View file

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
part '_tokens_adapter.dart';
/// 5-step size scale (rows 15 in the spec).
enum SplitButtonM3ESize { xs, sm, md, lg, xl }
/// Base silhouette for the outer corners in resting state.
/// (Pressed state morphs using tokens regardless of the base.)
enum SplitButtonM3EShape { round, square }
/// Emphasis family (choose container/elevation per theme).
enum SplitButtonM3EEmphasis { filled, tonal, elevated, outlined, text }
/// Trailing icon alignment:
/// - opticalCenter apply per-size negative offset in resting (menu closed) state
/// - geometricCenter no offset, purely geometric center
enum SplitButtonM3ETrailingAlignment { opticalCenter, geometricCenter }
/// Public helpers to access tokens without exposing numbers.
extension SplitButtonM3ETokensX on SplitButtonM3ESize {
double get height => SplitButtonM3ETokens.height[this]!;
double get trailingWidthCentered =>
SplitButtonM3ETokens.trailingSegmentWidth[this]!;
double get innerCornerRadius => SplitButtonM3ETokens.innerCornerRadius[this]!;
double get innerPadding => SplitButtonM3ETokens.innerPadding[this]!;
double get menuIconOffsetUnselected =>
SplitButtonM3ETokens.menuIconOffsetUnselected[this]!;
double get iconPx => SplitButtonM3ETokens.icon[this]!;
double get outerRoundRadius => SplitButtonM3ETokens.outerRadiusRound[this]!;
double get outerSquareRadius => SplitButtonM3ETokens.outerRadiusSquare[this]!;
double get pressedRadius => SplitButtonM3ETokens.pressedRadius[this]!;
// New layout getters (per spec tables)
double get leadingIconBlockWidth =>
SplitButtonM3ETokens.leadingIconBlockWidth[this]!;
double get leftOuterPadding => SplitButtonM3ETokens.leftOuterPadding[this]!;
double get gapIconToLabel => SplitButtonM3ETokens.gapIconToLabel[this]!;
double get labelRightPaddingBeforeDivider =>
SplitButtonM3ETokens.labelRightPaddingBeforeDivider[this]!;
double get trailingLeftInnerPadding =>
SplitButtonM3ETokens.trailingLeftInnerPadding[this]!;
double get rightOuterPadding => SplitButtonM3ETokens.rightOuterPadding[this]!;
double get sidePaddingSelected =>
SplitButtonM3ETokens.sidePaddingSelected[this]!;
}

View file

@ -0,0 +1,12 @@
/// Simple generic menu item model for SplitButtonM3E.
class SplitButtonM3EItem<T> {
const SplitButtonM3EItem({
required this.value,
required this.child,
this.enabled = true,
});
final T value;
final Object child; // Widget or plain string; the caller builds PopupMenuItem
final bool enabled;
}

View file

@ -0,0 +1,578 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'menu_items.dart';
/// Two-segment Material 3 Expressive split button.
///
/// - Leading segment: primary action (icon, label, or both)
/// - Trailing segment: menu trigger (chevron), opens a menu of alternatives
///
/// All numeric values (sizes, paddings, radii, durations) are pulled from
/// `tokens.dart` via the enums extension getters.
class SplitButtonM3E<T> extends StatefulWidget {
const SplitButtonM3E({
super.key,
this.shape = SplitButtonM3EShape.round,
this.size = SplitButtonM3ESize.sm,
this.emphasis = SplitButtonM3EEmphasis.filled,
this.label,
this.leadingIcon,
this.onPressed,
required this.items,
this.onSelected,
this.trailingAlignment = SplitButtonM3ETrailingAlignment.opticalCenter,
this.leadingTooltip,
this.trailingTooltip,
this.enabled = true,
this.menuBuilder,
}) : assert(
items != null || menuBuilder != null,
'Provide either `items` or `menuBuilder`.',
);
/// Size row (XSXL).
final SplitButtonM3ESize size;
/// Resting outer shape (round/square). Pressed morph uses tokens.
final SplitButtonM3EShape shape;
/// Visual emphasis family.
final SplitButtonM3EEmphasis emphasis;
/// Leading segment content.
final String? label;
final IconData? leadingIcon;
/// Leading action.
final VoidCallback? onPressed;
/// Trailing menu definition. Use either a static list...
final List<SplitButtonM3EItem<T>>? items;
/// ...or a builder that returns a list of PopupMenuEntries.
final List<PopupMenuEntry<T>> Function(BuildContext)? menuBuilder;
/// Called when a menu item is selected.
final ValueChanged<T>? onSelected;
/// Trailing chevron alignment strategy.
final SplitButtonM3ETrailingAlignment trailingAlignment;
/// Optional tooltips.
final String? leadingTooltip;
final String? trailingTooltip;
/// Set to false to disable both segments.
final bool enabled;
@override
State<SplitButtonM3E<T>> createState() => _SplitButtonM3EState<T>();
}
class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
bool _leadingPressed = false;
bool _trailingPressed = false;
bool _menuOpen = false;
final GlobalKey _trailingKey = GlobalKey();
@override
Widget build(BuildContext context) {
final dir = Directionality.of(context);
// Container/foreground colors per emphasis family
final (
Color cont,
Color onCont,
BorderSide? outlineSide,
double? elevation,
) = _resolveColorsAndShapes(
context,
);
final height = widget.size.height;
const minTap = SplitButtonM3ETokens.minTapTarget;
final outerRadius = switch (widget.shape) {
SplitButtonM3EShape.round => widget.size.outerRoundRadius,
SplitButtonM3EShape.square => widget.size.outerSquareRadius,
};
final pressedRadius = widget.size.pressedRadius;
final innerRadius = widget.size.innerCornerRadius;
const innerGap = SplitButtonM3ETokens.innerGap;
final chevronTurns = _menuOpen
? SplitButtonM3ETokens.chevronOpenTurns
: 0.0;
// Build segments
final leading = _SegmentContainer(
height: height,
minTapHeight: minTap,
color: cont,
onColor: onCont,
elevation: elevation,
outlineSide: outlineSide,
pressed: _leadingPressed,
radius: _leadingRadii(
dir: dir,
outer: outerRadius,
inner: innerRadius,
pressed: _leadingPressed ? pressedRadius : null,
),
tooltip: widget.leadingTooltip,
onHighlightChanged: (v) {
if (!widget.enabled) return;
setState(() => _leadingPressed = v);
},
onTap: widget.enabled ? widget.onPressed : null,
child: _LeadingContent(
size: widget.size,
icon: widget.leadingIcon,
label: widget.label,
color: onCont,
),
);
final trailingIconOffsetBase =
(widget.trailingAlignment ==
SplitButtonM3ETrailingAlignment.opticalCenter &&
!_menuOpen)
? widget.size.menuIconOffsetUnselected
: 0.0;
// Trailing segment total width per state (asymmetrical vs symmetrical)
final trailingWidthUnselected =
widget.size.trailingLeftInnerPadding +
widget.size.trailingWidthCentered +
widget.size.rightOuterPadding;
final trailingWidthSelected =
widget.size.sidePaddingSelected * 2 + widget.size.trailingWidthCentered;
// When round + pressed/open, morph trailing into a perfect circle
final bool allowCircle =
widget.size == SplitButtonM3ESize.md ||
widget.size == SplitButtonM3ESize.lg ||
widget.size == SplitButtonM3ESize.xl;
final bool circleTrailing =
widget.shape == SplitButtonM3EShape.round &&
allowCircle &&
(_trailingPressed || _menuOpen);
// XS/SM selected: fully rounded (capsule), not a circle
final bool smallSelectedCapsule =
widget.shape == SplitButtonM3EShape.round &&
(widget.size == SplitButtonM3ESize.xs ||
widget.size == SplitButtonM3ESize.sm) &&
_menuOpen;
final trailingFixedWidth = circleTrailing
? height
: (_menuOpen ? trailingWidthSelected : trailingWidthUnselected);
final trailingLeftPad = circleTrailing
? 0.0
: (_menuOpen
? widget.size.sidePaddingSelected
: widget.size.trailingLeftInnerPadding);
final trailingRightPad = circleTrailing
? 0.0
: (_menuOpen
? widget.size.sidePaddingSelected
: widget.size.rightOuterPadding);
final trailingChevronDx = circleTrailing ? 0.0 : trailingIconOffsetBase;
final trailingRadius = circleTrailing
? _CornerRadii(
topStart: height / 2,
bottomStart: height / 2,
topEnd: height / 2,
bottomEnd: height / 2,
)
: smallSelectedCapsule
? _CornerRadii(
topStart: height / 2,
bottomStart: height / 2,
topEnd: height / 2,
bottomEnd: height / 2,
)
: _trailingRadii(
dir: dir,
outer: outerRadius,
inner: innerRadius,
pressed: (_trailingPressed || _menuOpen) ? pressedRadius : null,
);
final trailing = KeyedSubtree(
key: _trailingKey,
child: _SegmentContainer(
height: height,
minTapHeight: minTap,
fixedWidth: trailingFixedWidth,
color: cont,
onColor: onCont,
elevation: elevation,
outlineSide: outlineSide,
pressed: _trailingPressed || _menuOpen,
radius: trailingRadius,
tooltip: widget.trailingTooltip,
onHighlightChanged: (v) {
if (!widget.enabled) return;
setState(() => _trailingPressed = v);
},
onTap: widget.enabled ? () => _openMenu(context) : null,
child: Padding(
padding: EdgeInsetsDirectional.only(
start: trailingLeftPad,
end: trailingRightPad,
),
child: SizedBox(
width: circleTrailing ? height : widget.size.trailingWidthCentered,
child: Center(
child: _TrailingChevron(
color: onCont,
size: widget.size.iconPx,
turns: chevronTurns,
dxOffset: trailingChevronDx,
),
),
),
),
),
);
return FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: minTap),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: dir,
children: [
leading,
const SizedBox(width: innerGap),
trailing,
],
),
),
);
}
(Color, Color, BorderSide?, double?) _resolveColorsAndShapes(
BuildContext context,
) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
switch (widget.emphasis) {
case SplitButtonM3EEmphasis.filled:
return (cs.primary, cs.onPrimary, null, null);
case SplitButtonM3EEmphasis.tonal:
return (cs.secondaryContainer, cs.onSecondaryContainer, null, null);
case SplitButtonM3EEmphasis.elevated:
return (
theme.colorScheme.surfaceContainerHigh,
cs.onSurface,
null,
1.0,
);
case SplitButtonM3EEmphasis.outlined:
final side = BorderSide(color: cs.outline);
return (Colors.transparent, cs.primary, side, null);
case SplitButtonM3EEmphasis.text:
return (Colors.transparent, cs.primary, null, null);
}
}
_CornerRadii _leadingRadii({
required TextDirection dir,
required double outer,
required double inner,
double? pressed,
}) {
final o = pressed ?? outer;
final i = pressed ?? inner;
// Leading segment: outer = start corners, inner = end corners
return _CornerRadii(topStart: o, bottomStart: o, topEnd: i, bottomEnd: i);
}
_CornerRadii _trailingRadii({
required TextDirection dir,
required double outer,
required double inner,
double? pressed,
}) {
final o = pressed ?? outer;
final i = pressed ?? inner;
// Trailing segment: inner = start corners, outer = end corners
return _CornerRadii(topStart: i, bottomStart: i, topEnd: o, bottomEnd: o);
}
Future<void> _openMenu(BuildContext context) async {
if (widget.menuBuilder != null) {
setState(() => _menuOpen = true);
final res = await showMenu<T>(
context: context,
position: _menuPosition(context),
items: widget.menuBuilder!(context),
);
if (mounted) {
setState(() => _menuOpen = false);
if (res != null && widget.onSelected != null) widget.onSelected!(res);
}
return;
}
// Convert simple items to PopupMenuEntries
final items = widget.items!;
setState(() => _menuOpen = true);
final res = await showMenu<T>(
context: context,
position: _menuPosition(context),
items: items
.map(
(e) => PopupMenuItem<T>(
value: e.value,
enabled: e.enabled,
child: e.child is Widget ? e.child as Widget : Text('${e.child}'),
),
)
.toList(),
);
if (!mounted) return;
setState(() => _menuOpen = false);
if (res != null && widget.onSelected != null) widget.onSelected!(res);
}
RelativeRect _menuPosition(BuildContext context) {
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final textDir = Directionality.of(context);
// Default to whole control if trailing key is missing
RenderBox? tb;
Offset tTopLeft = Offset.zero;
Size tSize = Size.zero;
final tCtx = _trailingKey.currentContext;
if (tCtx != null) {
tb = tCtx.findRenderObject() as RenderBox?;
}
if (tb != null) {
tTopLeft = tb.localToGlobal(Offset.zero);
tSize = tb.size;
} else {
final box = context.findRenderObject() as RenderBox?;
if (box != null) {
tTopLeft = box.localToGlobal(Offset.zero);
tSize = box.size;
}
}
final top = tTopLeft.dy + tSize.height;
late double left;
late double right;
if (textDir == TextDirection.ltr) {
final endX = tTopLeft.dx + tSize.width; // right edge
left = endX;
right = overlay.size.width - endX;
} else {
final startX = tTopLeft.dx; // left edge is trailing in RTL
left = startX;
right = overlay.size.width - startX;
}
return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top);
}
}
/// --- Internal: segment container ------------------------------------------------
class _SegmentContainer extends StatelessWidget {
const _SegmentContainer({
required this.height,
required this.minTapHeight,
required this.color,
required this.onColor,
this.fixedWidth,
this.elevation,
this.outlineSide,
required this.pressed,
required this.radius,
required this.child,
required this.onHighlightChanged,
required this.onTap,
this.tooltip,
});
final double height;
final double minTapHeight;
final double? fixedWidth;
final Color color;
final Color onColor;
final double? elevation;
final BorderSide? outlineSide;
final bool pressed;
final _CornerRadii radius;
final Widget child;
final ValueChanged<bool> onHighlightChanged;
final VoidCallback? onTap;
final String? tooltip;
@override
Widget build(BuildContext context) {
final shape = RoundedRectangleBorder(
borderRadius: radius.toBorderRadius(Directionality.of(context)),
side: outlineSide ?? BorderSide.none,
);
final button = Center(
child: Material(
color: color,
elevation: elevation ?? 0,
shape: shape,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onHighlightChanged: onHighlightChanged,
customBorder: shape,
child: SizedBox(
height: height,
width: fixedWidth,
child: Center(child: child),
),
),
),
);
final cont = ConstrainedBox(
constraints: BoxConstraints(minWidth: 0, minHeight: minTapHeight),
child: button,
);
if (tooltip == null) return cont;
return Tooltip(message: tooltip!, child: cont);
}
}
/// --- Internal: leading content --------------------------------------------------
class _LeadingContent extends StatelessWidget {
const _LeadingContent({
required this.size,
required this.icon,
required this.label,
required this.color,
});
final SplitButtonM3ESize size;
final IconData? icon;
final String? label;
final Color color;
@override
Widget build(BuildContext context) {
final m3e = context.m3e;
final iconSize = size.iconPx;
final lp = size.leftOuterPadding;
final rp = size.labelRightPaddingBeforeDivider;
final iconBlock = size.leadingIconBlockWidth;
final gap = size.gapIconToLabel;
Widget content;
if (icon != null && (label?.isNotEmpty ?? false)) {
content = Padding(
padding: EdgeInsetsDirectional.only(start: lp, end: rp),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: iconBlock,
child: Center(
child: Icon(icon, size: iconSize, color: color),
),
),
SizedBox(width: gap),
Flexible(
child: Text(
label!,
overflow: TextOverflow.ellipsis,
style: m3e.typography.base.labelLarge?.copyWith(color: color),
),
),
],
),
);
} else if (icon != null) {
content = Padding(
padding: EdgeInsetsDirectional.only(start: lp, end: rp),
child: SizedBox(
width: iconBlock,
child: Center(
child: Icon(icon, size: iconSize, color: color),
),
),
);
} else {
content = Padding(
padding: EdgeInsetsDirectional.only(start: lp, end: rp),
child: Text(
label ?? '',
overflow: TextOverflow.ellipsis,
style: DefaultTextStyle.of(context).style.copyWith(color: color),
),
);
}
return content;
}
}
/// --- Internal: trailing chevron -------------------------------------------------
class _TrailingChevron extends StatelessWidget {
const _TrailingChevron({
required this.color,
required this.size,
required this.turns,
required this.dxOffset,
});
final Color color;
final double size;
final double turns;
final double dxOffset;
@override
Widget build(BuildContext context) {
final icon = Icon(Icons.keyboard_arrow_down, size: size, color: color);
return AnimatedRotation(
duration: SplitButtonM3ETokens.chevronDuration,
turns: turns,
curve: SplitButtonM3ETokens.morphCurve,
child: Transform.translate(offset: Offset(dxOffset, 0.0), child: icon),
);
}
}
/// --- Internal: corner radii helper (private to this file) -----------------------
class _CornerRadii {
const _CornerRadii({
required this.topStart,
required this.bottomStart,
required this.topEnd,
required this.bottomEnd,
});
final double topStart, bottomStart, topEnd, bottomEnd;
BorderRadius toBorderRadius(TextDirection direction) {
return BorderRadiusDirectional.only(
topStart: Radius.circular(topStart),
bottomStart: Radius.circular(bottomStart),
topEnd: Radius.circular(topEnd),
bottomEnd: Radius.circular(bottomEnd),
).resolve(direction);
}
}

View file

@ -0,0 +1,22 @@
name: split_button_m3e
description: "Material 3 Expressive Split Button with sizes, variants, shapes, a11y, and menu."
version: 0.1.0
repository: https://github.com/EmilyMonestone/split_button_m3e
issue_tracker: https://github.com/EmilyMonestone/split_button_m3e/issues
environment:
sdk: ^3.9.2
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
m3e_design:
path: ../m3e_design
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true

View file

@ -0,0 +1,4 @@
# melos_managed_dependency_overrides: m3e_design
dependency_overrides:
m3e_design:
path: ..\\m3e_design

View file

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:split_button_m3e/split_button_m3e.dart';
void main() {
testWidgets('Semantics: primary and trigger have labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: true),
home: Material(
child: SplitButtonM3E<String>(
size: SplitButtonM3ESize.md,
shape: SplitButtonM3EShape.round,
label: 'Download',
leadingIcon: Icons.download_outlined,
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(
value: 'zip',
child: 'Download as ZIP',
),
SplitButtonM3EItem<String>(
value: 'pdf',
child: 'Download as PDF',
),
],
trailingTooltip: 'Open menu',
// Optional leading tooltip to also tag the primary segment semantics
leadingTooltip: 'Download',
),
),
),
);
// Primary labeled as 'Download'
expect(find.bySemanticsLabel('Download'), findsWidgets);
// Trigger labeled as 'Open menu'
expect(find.bySemanticsLabel('Open menu'), findsOneWidget);
});
testWidgets('Hit targets are >= 48 when size is XS', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: true),
home: Material(
child: Center(
child: SplitButtonM3E<String>(
size: SplitButtonM3ESize.xs,
shape: SplitButtonM3EShape.round,
label: 'Edit',
leadingIcon: Icons.edit_outlined,
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'rename', child: 'Rename'),
],
// Use tooltips to ensure we measure the segment containers (not just Text)
leadingTooltip: 'Edit',
trailingTooltip: 'Open menu',
),
),
),
),
);
final primary = find.bySemanticsLabel('Edit');
final trigger = find.bySemanticsLabel('Open menu');
expect(tester.getSize(primary).height, greaterThanOrEqualTo(48));
expect(tester.getSize(trigger).height, greaterThanOrEqualTo(48));
});
}