forked from mirrors/material_3_expressive
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:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
31
packages/split_button_m3e/.gitignore
vendored
Normal file
31
packages/split_button_m3e/.gitignore
vendored
Normal 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/
|
||||
10
packages/split_button_m3e/.metadata
Normal file
10
packages/split_button_m3e/.metadata
Normal 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
|
||||
7
packages/split_button_m3e/CHANGELOG.md
Normal file
7
packages/split_button_m3e/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
## 0.1.0
|
||||
|
||||
- Initial release: SplitButtonM3E (Material 3 Expressive)
|
||||
- Sizes XS–XL, variants filled/tonal/outlined/elevated
|
||||
- A11y: 48×48 min tap targets for each segment
|
||||
- Menu via MenuAnchor, caret rotation, keyboard support
|
||||
- Example app + tests
|
||||
21
packages/split_button_m3e/LICENSE
Normal file
21
packages/split_button_m3e/LICENSE
Normal 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.
|
||||
133
packages/split_button_m3e/README.md
Normal file
133
packages/split_button_m3e/README.md
Normal 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 arrow’s 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
|
||||
5
packages/split_button_m3e/analysis_options.yaml
Normal file
5
packages/split_button_m3e/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# include: package:flutter_lints/flutter.yaml
|
||||
linter:
|
||||
rules:
|
||||
prefer_single_quotes: true
|
||||
|
||||
45
packages/split_button_m3e/example/.gitignore
vendored
Normal file
45
packages/split_button_m3e/example/.gitignore
vendored
Normal 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
|
||||
45
packages/split_button_m3e/example/.metadata
Normal file
45
packages/split_button_m3e/example/.metadata
Normal 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'
|
||||
16
packages/split_button_m3e/example/README.md
Normal file
16
packages/split_button_m3e/example/README.md
Normal 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.
|
||||
28
packages/split_button_m3e/example/analysis_options.yaml
Normal file
28
packages/split_button_m3e/example/analysis_options.yaml
Normal 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
|
||||
114
packages/split_button_m3e/example/lib/main.dart
Normal file
114
packages/split_button_m3e/example/lib/main.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
21
packages/split_button_m3e/example/pubspec.yaml
Normal file
21
packages/split_button_m3e/example/pubspec.yaml
Normal 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
|
||||
5
packages/split_button_m3e/lib/split_button_m3e.dart
Normal file
5
packages/split_button_m3e/lib/split_button_m3e.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
library split_button_m3e;
|
||||
|
||||
export 'src/enums.dart';
|
||||
export 'src/menu_items.dart';
|
||||
export 'src/split_button.dart';
|
||||
160
packages/split_button_m3e/lib/src/_tokens_adapter.dart
Normal file
160
packages/split_button_m3e/lib/src/_tokens_adapter.dart
Normal 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;
|
||||
}
|
||||
46
packages/split_button_m3e/lib/src/enums.dart
Normal file
46
packages/split_button_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
part '_tokens_adapter.dart';
|
||||
|
||||
/// 5-step size scale (rows 1–5 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]!;
|
||||
}
|
||||
12
packages/split_button_m3e/lib/src/menu_items.dart
Normal file
12
packages/split_button_m3e/lib/src/menu_items.dart
Normal 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;
|
||||
}
|
||||
578
packages/split_button_m3e/lib/src/split_button.dart
Normal file
578
packages/split_button_m3e/lib/src/split_button.dart
Normal 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 (XS→XL).
|
||||
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);
|
||||
}
|
||||
}
|
||||
22
packages/split_button_m3e/pubspec.yaml
Normal file
22
packages/split_button_m3e/pubspec.yaml
Normal 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
|
||||
4
packages/split_button_m3e/pubspec_overrides.yaml
Normal file
4
packages/split_button_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# melos_managed_dependency_overrides: m3e_design
|
||||
dependency_overrides:
|
||||
m3e_design:
|
||||
path: ..\\m3e_design
|
||||
70
packages/split_button_m3e/test/split_button_m3e_test.dart
Normal file
70
packages/split_button_m3e/test/split_button_m3e_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue