diff --git a/docs/pages/docs/accordion.mdx b/docs/pages/docs/accordion.mdx
new file mode 100644
index 000000000..180687452
--- /dev/null
+++ b/docs/pages/docs/accordion.mdx
@@ -0,0 +1,142 @@
+import { Tabs } from 'nextra/components';
+import { Widget } from "../../components/widget";
+import LinkBadge from "../../components/link-badge/link-badge";
+import LinkBadgeGroup from "../../components/link-badge/link-badge-group";
+
+# Accordion
+A vertically stacked set of interactive headings that each reveal a section of content.
+
+
+
+
+
+
+
+
+
+
+ ```dart
+ const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FAccordion(
+ controller: FAccordionController(max: 2),
+ items: [
+ FAccordionItem(
+ title: const Text('Is it accessible?'),
+ child: const Text('Yes. It adheres to the WAI-ARIA design pattern.'),
+ ),
+ FAccordionItem(
+ initiallyExpanded: true,
+ title: const Text('Is it Styled?'),
+ child: const Text(
+ "Yes. It comes with default styles that matches the other components' aesthetics",
+ ),
+ ),
+ FAccordionItem(
+ title: const Text('Is it Animated?'),
+ child: const Text(
+ 'Yes. It is animated by default, but you can disable it if you prefer',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ ```
+
+
+
+## Usage
+
+### `FAccordion(...)`
+
+```dart
+FAccordion(
+ controller: FAccordionController(min: 1, max: 2), // or FAccordionController.radio()
+ items: [
+ FAccordionItem(
+ title: const Text('Is it accessible?'),
+ child: const Text('Yes. It adheres to the WAI-ARIA design pattern.'),
+ ),
+ ],
+),
+```
+
+## Examples
+
+### With Radio Behaviour
+
+
+
+
+
+ ```dart {5}
+ const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FAccordion(
+ controller: FAccordionController.radio(),
+ items: [
+ FAccordionItem(
+ title: const Text('Is it accessible?'),
+ child: const Text('Yes. It adheres to the WAI-ARIA design pattern.'),
+ ),
+ FAccordionItem(
+ title: const Text('Is it Styled?'),
+ initiallyExpanded: true,
+ child: const Text(
+ "Yes. It comes with default styles that matches the other components' aesthetics",
+ ),
+ ),
+ FAccordionItem(
+ title: const Text('Is it Animated?'),
+ child: const Text(
+ 'Yes. It is animated by default, but you can disable it if you prefer',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ ```
+
+
+
+### With Multi-select Behaviour
+
+
+
+
+
+ ```dart {5}
+ const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FAccordion(
+ controller: FAccordionController(),
+ items: [
+ FAccordionItem(
+ title: const Text('Is it accessible?'),
+ child: const Text('Yes. It adheres to the WAI-ARIA design pattern.'),
+ ),
+ FAccordionItem(
+ initiallyExpanded: true,
+ title: const Text('Is it Styled?'),
+ child: const Text(
+ "Yes. It comes with default styles that matches the other components' aesthetics",
+ ),
+ ),
+ FAccordionItem(
+ title: const Text('Is it Animated?'),
+ child: const Text(
+ 'Yes. It is animated by default, but you can disable it if you prefer',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ ```
+
+
\ No newline at end of file
diff --git a/forui/example/lib/sandbox.dart b/forui/example/lib/sandbox.dart
index 4668ee3a8..d7f996633 100644
--- a/forui/example/lib/sandbox.dart
+++ b/forui/example/lib/sandbox.dart
@@ -19,7 +19,56 @@ class _SandboxState extends State {
}
@override
- Widget build(BuildContext context) => FSlider(
- controller: FContinuousSliderController.range(selection: FSliderSelection(min: 0.30, max: 0.35)),
+ Widget build(BuildContext context) => Column(
+ children: [
+ FAccordion(
+ controller: FAccordionController(max: 2),
+ children: [
+ const FAccordionItem(
+ title: Text('Title 1'),
+ initiallyExpanded: true,
+ child: Text(
+ 'Yes. It adheres to the WAI-ARIA design pattern, wfihwe fdhfiwf dfhwiodf dfwhoif',
+ ),
+ ),
+ FAccordionItem(
+ title: const Text('Title 2'),
+ child: Container(
+ width: 100,
+ color: Colors.yellow,
+ child: const Text(
+ 'Yes. It adheres to the WAI-ARIA design pattern geg wjfiweo dfjiowjf dfjio',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ const FAccordionItem(
+ title: Text('Title 3'),
+ child: Text(
+ 'Yes. It adheres to the WAI-ARIA design pattern',
+ textAlign: TextAlign.left,
+ ),
+ ),
+ const FAccordionItem(
+ title: Text('Title 4'),
+ child: Text(
+ 'Yes. It adheres to the WAI-ARIA design pattern',
+ textAlign: TextAlign.left,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ FSelectGroup(
+ label: const Text('Select Group'),
+ description: const Text('Select Group Description'),
+ controller: FMultiSelectGroupController(min: 1, max: 2, values: {1}),
+ items: [
+ FSelectGroupItem.checkbox(value: 1, label: const Text('Checkbox 1'), semanticLabel: 'Checkbox 1'),
+ FSelectGroupItem.checkbox(value: 2, label: const Text('Checkbox 2'), semanticLabel: 'Checkbox 2'),
+ FSelectGroupItem.checkbox(value: 3, label: const Text('Checkbox 3'), semanticLabel: 'Checkbox 3'),
+ ],
+ ),
+ ],
);
}
diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart
index bc0e4f1fa..248cc0135 100644
--- a/forui/lib/forui.dart
+++ b/forui/lib/forui.dart
@@ -5,6 +5,7 @@ export 'assets.dart';
export 'foundation.dart';
export 'theme.dart';
+export 'widgets/accordion.dart';
export 'widgets/alert.dart';
export 'widgets/avatar.dart';
export 'widgets/badge.dart';
diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart
index 49e4e20a7..b23712efa 100644
--- a/forui/lib/src/theme/theme_data.dart
+++ b/forui/lib/src/theme/theme_data.dart
@@ -25,6 +25,9 @@ final class FThemeData with Diagnosticable {
/// The style. It is used to configure the miscellaneous properties, such as border radii, of Forui widgets.
final FStyle style;
+ /// The accordion style.
+ final FAccordionStyle accordionStyle;
+
/// The alert styles.
final FAlertStyles alertStyles;
@@ -103,6 +106,7 @@ final class FThemeData with Diagnosticable {
FThemeData({
required this.colorScheme,
required this.style,
+ required this.accordionStyle,
required this.alertStyles,
required this.avatarStyle,
required this.badgeStyles,
@@ -141,6 +145,7 @@ final class FThemeData with Diagnosticable {
colorScheme: colorScheme,
typography: typography,
style: style,
+ accordionStyle: FAccordionStyle.inherit(colorScheme: colorScheme, typography: typography),
alertStyles: FAlertStyles.inherit(colorScheme: colorScheme, typography: typography, style: style),
avatarStyle: FAvatarStyle.inherit(colorScheme: colorScheme, typography: typography),
badgeStyles: FBadgeStyles.inherit(colorScheme: colorScheme, typography: typography, style: style),
@@ -214,6 +219,7 @@ final class FThemeData with Diagnosticable {
///```
@useResult
FThemeData copyWith({
+ FAccordionStyle? accordionStyle,
FAlertStyles? alertStyles,
FAvatarStyle? avatarStyle,
FBadgeStyles? badgeStyles,
@@ -242,6 +248,7 @@ final class FThemeData with Diagnosticable {
colorScheme: colorScheme,
typography: typography,
style: style,
+ accordionStyle: accordionStyle ?? this.accordionStyle,
alertStyles: alertStyles ?? this.alertStyles,
avatarStyle: avatarStyle ?? this.avatarStyle,
badgeStyles: badgeStyles ?? this.badgeStyles,
@@ -274,6 +281,7 @@ final class FThemeData with Diagnosticable {
..add(DiagnosticsProperty('colorScheme', colorScheme, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('typography', typography, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug))
+ ..add(DiagnosticsProperty('accordionStyle', accordionStyle))
..add(DiagnosticsProperty('alertStyles', alertStyles, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('avatarStyle', avatarStyle, level: DiagnosticLevel.debug))
..add(DiagnosticsProperty('badgeStyles', badgeStyles, level: DiagnosticLevel.debug))
@@ -307,6 +315,7 @@ final class FThemeData with Diagnosticable {
colorScheme == other.colorScheme &&
typography == other.typography &&
style == other.style &&
+ accordionStyle == other.accordionStyle &&
alertStyles == other.alertStyles &&
avatarStyle == other.avatarStyle &&
badgeStyles == other.badgeStyles &&
@@ -336,6 +345,7 @@ final class FThemeData with Diagnosticable {
colorScheme.hashCode ^
typography.hashCode ^
style.hashCode ^
+ accordionStyle.hashCode ^
alertStyles.hashCode ^
avatarStyle.hashCode ^
badgeStyles.hashCode ^
diff --git a/forui/lib/src/widgets/accordion/accordion.dart b/forui/lib/src/widgets/accordion/accordion.dart
new file mode 100644
index 000000000..da89d97db
--- /dev/null
+++ b/forui/lib/src/widgets/accordion/accordion.dart
@@ -0,0 +1,220 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:flutter_svg/svg.dart';
+import 'package:meta/meta.dart';
+
+import 'package:forui/forui.dart';
+
+/// A vertically stacked set of interactive headings that each reveal a section of content.
+///
+///
+/// See:
+/// * https://forui.dev/docs/accordion for working examples.
+/// * [FAccordionController] for customizing the accordion's selection behavior.
+/// * [FAccordionItem] for adding items to an accordion.
+/// * [FAccordionStyle] for customizing an accordion's appearance.
+class FAccordion extends StatefulWidget {
+ /// The controller.
+ ///
+ /// See:
+ /// * [FAccordionController] for default multiple selections.
+ /// * [FAccordionController.radio] for a radio-like selection.
+ final FAccordionController? controller;
+
+ /// The style. Defaults to [FThemeData.accordionStyle].
+ final FAccordionStyle? style;
+
+ /// The items.
+ final List children;
+
+ /// Creates a [FAccordion].
+ const FAccordion({
+ required this.children,
+ this.controller,
+ this.style,
+ super.key,
+ });
+
+ @override
+ State createState() => _FAccordionState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('controller', controller))
+ ..add(DiagnosticsProperty('style', style))
+ ..add(IterableProperty('items', children));
+ }
+}
+
+class _FAccordionState extends State {
+ late FAccordionController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = widget.controller ?? FAccordionController();
+
+ if (!_controller.validate(widget.children.where((child) => child.initiallyExpanded).length)) {
+ throw StateError('number of expanded items must be within the allowed range.');
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant FAccordion oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ if (widget.controller != oldWidget.controller) {
+ _controller = widget.controller ?? FAccordionController();
+
+ if (!_controller.validate(widget.children.where((child) => child.initiallyExpanded).length)) {
+ throw StateError('number of expanded items must be within the min and max.');
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => Column(
+ children: [
+ for (final (index, child) in widget.children.indexed)
+ FAccordionItemData(
+ index: index,
+ controller: _controller,
+ child: child,
+ ),
+ ],
+ );
+}
+
+/// The [FAccordion]'s style.
+final class FAccordionStyle with Diagnosticable {
+ /// The title's default text style.
+ final TextStyle titleTextStyle;
+
+ /// The child's default text style.
+ final TextStyle childTextStyle;
+
+ /// The padding around the title.
+ final EdgeInsets titlePadding;
+
+ /// The padding around the content.
+ final EdgeInsets childPadding;
+
+ /// The icon.
+ final Widget icon;
+
+ /// The divider's color.
+ final FDividerStyle divider;
+
+ /// Creates a [FAccordionStyle].
+ FAccordionStyle({
+ required this.titleTextStyle,
+ required this.childTextStyle,
+ required this.titlePadding,
+ required this.childPadding,
+ required this.icon,
+ required this.divider,
+ });
+
+ /// Creates a [FDividerStyles] that inherits its properties from [colorScheme].
+ FAccordionStyle.inherit({required FColorScheme colorScheme, required FTypography typography})
+ : titleTextStyle = typography.base.copyWith(
+ fontWeight: FontWeight.w500,
+ color: colorScheme.foreground,
+ ),
+ childTextStyle = typography.sm.copyWith(
+ color: colorScheme.foreground,
+ ),
+ titlePadding = const EdgeInsets.symmetric(vertical: 15),
+ childPadding = const EdgeInsets.only(bottom: 15),
+ icon = FAssets.icons.chevronRight(
+ height: 20,
+ colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn),
+ ),
+ divider = FDividerStyle(color: colorScheme.border, padding: EdgeInsets.zero);
+
+ /// Returns a copy of this [FAccordionStyle] with the given properties replaced.
+ @useResult
+ FAccordionStyle copyWith({
+ TextStyle? titleTextStyle,
+ TextStyle? childTextStyle,
+ EdgeInsets? titlePadding,
+ EdgeInsets? childPadding,
+ SvgPicture? icon,
+ FDividerStyle? divider,
+ }) =>
+ FAccordionStyle(
+ titleTextStyle: titleTextStyle ?? this.titleTextStyle,
+ childTextStyle: childTextStyle ?? this.childTextStyle,
+ titlePadding: titlePadding ?? this.titlePadding,
+ childPadding: childPadding ?? this.childPadding,
+ icon: icon ?? this.icon,
+ divider: divider ?? this.divider,
+ );
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('title', titleTextStyle))
+ ..add(DiagnosticsProperty('childTextStyle', childTextStyle))
+ ..add(DiagnosticsProperty('padding', titlePadding))
+ ..add(DiagnosticsProperty('contentPadding', childPadding))
+ ..add(DiagnosticsProperty('divider', divider));
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is FAccordionStyle &&
+ runtimeType == other.runtimeType &&
+ titleTextStyle == other.titleTextStyle &&
+ childTextStyle == other.childTextStyle &&
+ titlePadding == other.titlePadding &&
+ childPadding == other.childPadding &&
+ icon == other.icon &&
+ divider == other.divider;
+
+ @override
+ int get hashCode =>
+ titleTextStyle.hashCode ^
+ childTextStyle.hashCode ^
+ titlePadding.hashCode ^
+ childPadding.hashCode ^
+ icon.hashCode ^
+ divider.hashCode;
+}
+
+@internal
+class FAccordionItemData extends InheritedWidget {
+ @useResult
+ static FAccordionItemData of(BuildContext context) {
+ final data = context.dependOnInheritedWidgetOfExactType();
+ assert(data != null, 'No FAccordionItemData found in context');
+ return data!;
+ }
+
+ final int index;
+
+ final FAccordionController controller;
+
+ const FAccordionItemData({
+ required this.index,
+ required this.controller,
+ required super.child,
+ super.key,
+ });
+
+ @override
+ bool updateShouldNotify(covariant FAccordionItemData old) => index != old.index || controller != old.controller;
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(IntProperty('index', index))
+ ..add(DiagnosticsProperty('controller', controller));
+ }
+}
diff --git a/forui/lib/src/widgets/accordion/accordion_controller.dart b/forui/lib/src/widgets/accordion/accordion_controller.dart
new file mode 100644
index 000000000..72fc37f64
--- /dev/null
+++ b/forui/lib/src/widgets/accordion/accordion_controller.dart
@@ -0,0 +1,142 @@
+import 'package:flutter/widgets.dart';
+
+/// A controller that controls which sections are shown and hidden.
+class FAccordionController extends ChangeNotifier {
+ /// The duration of the expanding and collapsing animations.
+ final Duration animationDuration;
+
+ /// The animation controllers for each of the sections in the accordion.
+ final Map controllers;
+ final Set _expanded;
+ final int _min;
+ final int? _max;
+
+ /// Creates an [FAccordionController] that allows only one section to be expanded at a time.
+ factory FAccordionController.radio({Duration? animationDuration}) => FAccordionController(
+ max: 1,
+ animationDuration: animationDuration ?? const Duration(milliseconds: 200),
+ );
+
+ /// Creates a [FAccordionController].
+ ///
+ /// The [min], inclusive, and [max], inclusive, values are the minimum and maximum number of selections allowed.
+ /// Defaults to no minimum and maximum.
+ ///
+ /// # Contract:
+ /// * Throws [AssertionError] if [min] < 0.
+ /// * Throws [AssertionError] if [max] < 0.
+ /// * Throws [AssertionError] if [min] > [max].
+ FAccordionController({
+ int min = 0,
+ int? max,
+ this.animationDuration = const Duration(milliseconds: 200),
+ }) : _min = min,
+ _max = max,
+ controllers = {},
+ _expanded = {},
+ assert(min >= 0, 'The min value must be greater than or equal to 0.'),
+ assert(max == null || max >= 0, 'The max value must be greater than or equal to 0.'),
+ assert(max == null || min <= max, 'The max value must be greater than or equal to the min value.');
+
+ /// Adds an item to the accordion.
+ Future addItem(
+ int index,
+ AnimationController controller,
+ Animation animation, {
+ required bool initiallyExpanded,
+ }) async {
+ controller
+ ..value = initiallyExpanded ? 1 : 0
+ ..duration = animationDuration;
+
+ controllers[index] = (controller: controller, animation: animation);
+
+ if (initiallyExpanded) {
+ if (_max != null && _expanded.length >= _max) {
+ if (!await _collapse(expanded.first)) {
+ return;
+ }
+ }
+ _expanded.add(index);
+ }
+ }
+
+ /// Removes the item at the given [index] from the accordion. Returns true if the item was removed.
+ bool removeItem(int index) {
+ if (_expanded.length <= _min && _expanded.contains(index)) {
+ return false;
+ }
+ final removed = controllers.remove(index);
+ _expanded.remove(index);
+ return removed != null;
+ }
+
+ /// Convenience method for toggling the current expansion status.
+ ///
+ /// This method should typically not be called while the widget tree is being rebuilt.
+ Future toggle(int index) async {
+ final value = controllers[index]?.animation.value;
+
+ if (value == null) {
+ return;
+ }
+
+ value == 100 ? await collapse(index) : await expand(index);
+ }
+
+ /// Expands the item at the given [index].
+ ///
+ /// This method should typically not be called while the widget tree is being rebuilt.
+ Future expand(int index) async {
+ if (_expanded.contains(index) || controllers[index] == null) {
+ return;
+ }
+
+ final futures = >[];
+ if (_max != null && _expanded.length >= _max) {
+ futures.add(_collapse(_expanded.first));
+ }
+
+ _expanded.add(index);
+
+ final future = controllers[index]?.controller.forward();
+ if (future != null) {
+ futures.add(future);
+ }
+ await Future.wait(futures);
+
+ notifyListeners();
+ }
+
+ /// Collapses the item at the given [index].
+ ///
+ /// This method should typically not be called while the widget tree is being rebuilt.
+ Future collapse(int index) async {
+ if (await _collapse(index)) {
+ notifyListeners();
+ }
+ }
+
+ Future _collapse(int index) async {
+ if (_expanded.length <= _min || !_expanded.contains(index)) {
+ return false;
+ }
+
+ _expanded.remove(index);
+
+ await controllers[index]?.controller.reverse();
+ return true;
+ }
+
+ /// Returns true if the number of expanded items is within the allowed range.
+ bool validate(int length) => length >= _min && (_max == null || length <= _max);
+
+ /// The currently selected values.
+ Set get expanded => {..._expanded};
+
+ /// Removes all objects from the expanded and controller list;
+ void clear() {
+ _expanded.clear();
+ controllers.clear();
+ }
+}
diff --git a/forui/lib/src/widgets/accordion/accordion_item.dart b/forui/lib/src/widgets/accordion/accordion_item.dart
new file mode 100644
index 000000000..1af6a90b0
--- /dev/null
+++ b/forui/lib/src/widgets/accordion/accordion_item.dart
@@ -0,0 +1,217 @@
+import 'dart:math' as math;
+
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/foundation/tappable.dart';
+import 'package:forui/src/foundation/util.dart';
+import 'package:forui/src/widgets/accordion/accordion.dart';
+
+/// An interactive heading that reveals a section of content.
+///
+/// See:
+/// * https://forui.dev/docs/accordion for working examples.
+class FAccordionItem extends StatefulWidget {
+ /// The accordion's style. Defaults to [FThemeData.accordionStyle].
+ final FAccordionStyle? style;
+
+ /// The title.
+ final Widget title;
+
+ /// True if the item is initially expanded.
+ final bool initiallyExpanded;
+
+ /// The child.
+ final Widget child;
+
+ /// Creates an [FAccordionItem].
+ const FAccordionItem({
+ required this.title,
+ required this.child,
+ this.style,
+ this.initiallyExpanded = false,
+ super.key,
+ });
+
+ @override
+ State createState() => _FAccordionItemState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(FlagProperty('initiallyExpanded', value: initiallyExpanded, ifTrue: 'Initially expanded'));
+ }
+}
+
+class _FAccordionItemState extends State with TickerProviderStateMixin {
+ late AnimationController _controller;
+ late Animation _expand;
+
+ @override
+ Future didChangeDependencies() async {
+ super.didChangeDependencies();
+ final data = FAccordionItemData.of(context);
+
+ if (data.controller.removeItem(data.index)) {
+ _controller.dispose();
+ }
+
+ _controller = AnimationController(vsync: this);
+ _expand = Tween(
+ begin: 0,
+ end: 100,
+ ).animate(
+ CurvedAnimation(
+ curve: Curves.ease,
+ parent: _controller,
+ ),
+ );
+ await data.controller.addItem(data.index, _controller, _expand, initiallyExpanded: widget.initiallyExpanded);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final FAccordionItemData(:index, :controller) = FAccordionItemData.of(context);
+ final style = widget.style ?? context.theme.accordionStyle;
+ return AnimatedBuilder(
+ animation: _expand,
+ builder: (context, _) => Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ FTappable(
+ behavior: HitTestBehavior.translucent,
+ onPress: () => controller.toggle(index),
+ builder: (context, state, child) => Container(
+ padding: style.titlePadding,
+ child: Row(
+ children: [
+ Expanded(
+ child: merge(
+ // TODO: replace with DefaultTextStyle.merge when textHeightBehavior has been added.
+ textHeightBehavior: const TextHeightBehavior(
+ applyHeightToFirstAscent: false,
+ applyHeightToLastDescent: false,
+ ),
+ style: style.titleTextStyle.copyWith(
+ decoration:
+ state.hovered || state.shortPressed ? TextDecoration.underline : TextDecoration.none,
+ ),
+ child: widget.title,
+ ),
+ ),
+ child!,
+ ],
+ ),
+ ),
+ child: Transform.rotate(
+ angle: (_expand.value / 100 * -180 + 90) * math.pi / 180.0,
+ child: style.icon,
+ ),
+ ),
+ // We use a combination of a custom render box & clip rect to avoid visual oddities. This is caused by
+ // RenderPaddings (created by Paddings in the child) shrinking the constraints by the given padding, causing the
+ // child to layout at a smaller size while the amount of padding remains the same.
+ _Expandable(
+ percentage: _expand.value / 100,
+ child: ClipRect(
+ clipper: _Clipper(_expand.value / 100),
+ child: Padding(
+ padding: style.childPadding,
+ child: DefaultTextStyle(style: style.childTextStyle, child: widget.child),
+ ),
+ ),
+ ),
+ FDivider(
+ style: style.divider,
+ ),
+ ],
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+}
+
+class _Expandable extends SingleChildRenderObjectWidget {
+ final double _percentage;
+
+ const _Expandable({
+ required super.child,
+ required double percentage,
+ }) : _percentage = percentage;
+
+ @override
+ RenderObject createRenderObject(BuildContext context) => _ExpandableBox(_percentage);
+
+ @override
+ void updateRenderObject(BuildContext context, _ExpandableBox renderObject) => renderObject..percentage = _percentage;
+}
+
+class _ExpandableBox extends RenderBox with RenderObjectWithChildMixin {
+ double _percentage;
+
+ _ExpandableBox(double percentage) : _percentage = percentage;
+
+ @override
+ void performLayout() {
+ if (child case final child?) {
+ child.layout(constraints.normalize(), parentUsesSize: true);
+ size = Size(child.size.width, child.size.height * _percentage);
+ } else {
+ size = constraints.smallest;
+ }
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ if (child case final child?) {
+ context.paintChild(child, offset);
+ }
+ }
+
+ @override
+ bool hitTest(BoxHitTestResult result, {required Offset position}) {
+ if (size.contains(position) && child!.hitTest(result, position: position)) {
+ result.add(BoxHitTestEntry(this, position));
+ return true;
+ }
+
+ return false;
+ }
+
+ double get percentage => _percentage;
+
+ set percentage(double value) {
+ if (_percentage == value) {
+ return;
+ }
+
+ _percentage = value;
+ markNeedsLayout();
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DoubleProperty('percentage', percentage));
+ }
+}
+
+class _Clipper extends CustomClipper {
+ final double percentage;
+
+ _Clipper(this.percentage);
+
+ @override
+ Rect getClip(Size size) => Offset.zero & Size(size.width, size.height * percentage);
+
+ @override
+ bool shouldReclip(covariant _Clipper oldClipper) => oldClipper.percentage != percentage;
+}
diff --git a/forui/lib/widgets/accordion.dart b/forui/lib/widgets/accordion.dart
new file mode 100644
index 000000000..a156b637d
--- /dev/null
+++ b/forui/lib/widgets/accordion.dart
@@ -0,0 +1,10 @@
+/// {@category Widgets}
+///
+/// A vertically stacked set of interactive headings that each reveal a section of content.
+///
+/// See https://forui.dev/docs/accordion for working examples.
+library forui.widgets.accordion;
+
+export '../src/widgets/accordion/accordion.dart' hide FAccordionItemData;
+export '../src/widgets/accordion/accordion_controller.dart';
+export '../src/widgets/accordion/accordion_item.dart';
diff --git a/forui/test/golden/accordion/hidden.png b/forui/test/golden/accordion/hidden.png
new file mode 100644
index 000000000..666042c4e
Binary files /dev/null and b/forui/test/golden/accordion/hidden.png differ
diff --git a/forui/test/golden/accordion/shown.png b/forui/test/golden/accordion/shown.png
new file mode 100644
index 000000000..dcbb32834
Binary files /dev/null and b/forui/test/golden/accordion/shown.png differ
diff --git a/forui/test/src/widgets/accordion/accordion_controller_test.dart b/forui/test/src/widgets/accordion/accordion_controller_test.dart
new file mode 100644
index 000000000..6818850f6
--- /dev/null
+++ b/forui/test/src/widgets/accordion/accordion_controller_test.dart
@@ -0,0 +1,387 @@
+import 'package:flutter/animation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+
+import 'package:forui/forui.dart';
+import 'accordion_controller_test.mocks.dart';
+
+(List, List) _setup(int length) {
+ final animationControllers = [];
+ final animations = [];
+
+ for (int i = 0; i < length; i++) {
+ animationControllers.add(MockAnimationController());
+ animations.add(Tween(begin: 0, end: 100).animate(animationControllers[i]));
+ when(animationControllers[i].forward()).thenAnswer((_) {
+ when(animationControllers[i].value).thenReturn(1.0);
+ return TickerFuture.complete();
+ });
+
+ when(animationControllers[i].reverse()).thenAnswer((_) {
+ when(animationControllers[i].value).thenReturn(0.0);
+ return TickerFuture.complete();
+ });
+ }
+
+ return (animationControllers, animations);
+}
+
+void _tearDown(List animationControllers) {
+ for (final controller in animationControllers) {
+ controller.dispose();
+ }
+}
+
+@GenerateNiceMocks([MockSpec()])
+@GenerateNiceMocks([MockSpec()])
+void main() {
+ group('FAccordionController', () {
+ late FAccordionController controller;
+ List animationControllers = [];
+ List> animations = [];
+ int count = 0;
+ int length = 3;
+
+ setUp(() {
+ count = 0;
+ length = 3;
+ final record = _setup(length);
+ animationControllers = List.from(record.$1);
+ animations = List.from(record.$2);
+ controller = FAccordionController(min: 1, max: 2)
+ ..addListener(() {
+ count++;
+ });
+ });
+
+ tearDown(() {
+ _tearDown(animationControllers);
+ controller.dispose();
+ });
+
+ group('addItem(...)', () {
+ test('sets animation controller value based on initiallyExpanded', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ verify(animationControllers[0].value = 0);
+
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ verify(animationControllers[0].value = 1);
+ });
+
+ test('adds to expanded list', () {
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ expect(controller.expanded.length, 0);
+
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ expect(controller.expanded.length, 1);
+ expect(controller.controllers.length, 1);
+ });
+
+ test('aware of max limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: true);
+ await controller.addItem(2, animationControllers[2], animations[2], initiallyExpanded: true);
+ expect(controller.expanded, {1, 2});
+ });
+ });
+
+ group('removeItem(...)', () {
+ setUp(() {
+ length = 1;
+ final record = _setup(length);
+ animationControllers = List.from(record.$1);
+ animations = List.from(record.$2);
+ controller = FAccordionController(min: 1, max: 2)
+ ..addListener(() {
+ count++;
+ });
+ });
+
+ tearDown(() {
+ _tearDown(animationControllers);
+ });
+ test('removes from the expanded list', () {
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ expect(controller.removeItem(0), true);
+ expect(controller.removeItem(0), false);
+ });
+
+ test('aware of min limit', () {
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ expect(controller.removeItem(0), false);
+ });
+ });
+
+ group('toggle(...)', () {
+ test('expands an item', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ expect(controller.controllers[0]?.animation.value, 0);
+
+ await controller.toggle(0);
+ expect(controller.controllers[0]?.animation.value, 100);
+ });
+
+ test('collapses an item', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: true);
+ await animationControllers[0].forward();
+ expect(controller.controllers[0]?.animation.value, 100);
+
+ await controller.toggle(0);
+ expect(controller.controllers[0]?.animation.value, 0);
+ });
+
+ test('invalid index', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ await controller.toggle(1);
+ expect(count, 0);
+
+ await controller.toggle(0);
+ expect(count, 1);
+ });
+
+ test('aware of max limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: true);
+ await controller.addItem(2, animationControllers[2], animations[2], initiallyExpanded: false);
+ await animationControllers[0].forward();
+ await animationControllers[1].forward();
+ expect(controller.controllers[0]?.animation.value, 100);
+ expect(controller.controllers[1]?.animation.value, 100);
+
+ await controller.toggle(2);
+ expect(controller.controllers[0]?.animation.value, 0);
+ });
+
+ test('aware of min limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: false);
+ await controller.addItem(2, animationControllers[2], animations[2], initiallyExpanded: true);
+ await animationControllers[2].forward();
+ expect(controller.controllers[2]?.animation.value, 100);
+
+ await controller.toggle(2);
+ expect(controller.controllers[2]?.animation.value, 100);
+ });
+ });
+
+ group('expand(...)', () {
+ test('does not call notifyListener on invalid index', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ await controller.expand(0);
+ expect(controller.expanded, {0});
+
+ await controller.expand(0);
+ expect(count, 1);
+ await controller.expand(1);
+ expect(count, 1);
+ });
+
+ test('aware of max limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: true);
+ await controller.addItem(2, animationControllers[2], animations[2], initiallyExpanded: false);
+ await controller.expand(2);
+ expect(controller.expanded, {1, 2});
+ });
+ });
+
+ group('collapse(...)', () {
+ setUp(() {
+ length = 2;
+ final record = _setup(length);
+ animationControllers = List.from(record.$1);
+ animations = List.from(record.$2);
+ controller = FAccordionController(min: 1, max: 2)
+ ..addListener(() {
+ count++;
+ });
+ });
+
+ tearDown(() {
+ _tearDown(animationControllers);
+ });
+
+ test('does not call notifyListener on invalid index', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: true);
+ await controller.collapse(0);
+ expect(controller.expanded, {1});
+
+ await controller.collapse(0);
+ expect(count, 1);
+ await controller.collapse(2);
+ expect(count, 1);
+ });
+
+ test('aware of min limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.collapse(0);
+ expect(controller.expanded, {0});
+ });
+ });
+
+ test('validate(...)', () {
+ expect(controller.validate(0), false);
+ expect(controller.validate(1), true);
+ expect(controller.validate(2), true);
+ expect(controller.validate(3), false);
+ });
+ });
+
+ group('FAccordionController.radio', () {
+ late FAccordionController controller;
+ List animationControllers = [];
+ List> animations = [];
+ int count = 0;
+ int length = 2;
+
+ setUp(() {
+ count = 0;
+ length = 2;
+ final record = _setup(length);
+ animationControllers = List.from(record.$1);
+ animations = List.from(record.$2);
+ controller = FAccordionController.radio()
+ ..addListener(() {
+ count++;
+ });
+ });
+
+ tearDown(() {
+ _tearDown(animationControllers);
+ controller.dispose();
+ });
+
+ group('addItem(...)', () {
+ test('adds to expanded list', () {
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ expect(controller.expanded.length, 0);
+
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ expect(controller.expanded.length, 1);
+ expect(controller.controllers.length, 1);
+ });
+ test('aware of max limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: true);
+ expect(controller.expanded, {1});
+ });
+ });
+
+ group('removeItem(...)', () {
+ setUp(() {
+ length = 1;
+ final record = _setup(length);
+ animationControllers = List.from(record.$1);
+ animations = List.from(record.$2);
+ controller = FAccordionController.radio()
+ ..addListener(() {
+ count++;
+ });
+ });
+
+ tearDown(() {
+ _tearDown(animationControllers);
+ });
+
+ test('removes from the expanded list', () {
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ expect(controller.removeItem(0), true);
+ expect(controller.removeItem(0), false);
+ });
+ test('aware of min limit', () {
+ controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ expect(controller.removeItem(0), true);
+ });
+ });
+
+ group('toggle(...)', () {
+ test('expands an item', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ expect(controller.controllers[0]?.animation.value, 0);
+
+ await controller.toggle(0);
+ expect(controller.controllers[0]?.animation.value, 100);
+ });
+
+ test('collapses an item', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await animationControllers[0].forward();
+ expect(controller.controllers[0]?.animation.value, 100);
+
+ await controller.toggle(0);
+ expect(controller.controllers[0]?.animation.value, 0);
+ });
+
+ test('invalid index', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ await controller.toggle(1);
+
+ expect(count, 0);
+ await controller.toggle(0);
+ expect(count, 1);
+ });
+
+ test('aware of max limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: false);
+ await animationControllers[0].forward();
+ expect(controller.controllers[0]?.animation.value, 100);
+
+ await controller.toggle(1);
+ expect(controller.controllers[0]?.animation.value, 0);
+ });
+
+ test('aware of min limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await animationControllers[0].forward();
+ expect(controller.controllers[0]?.animation.value, 100);
+
+ await controller.toggle(0);
+ expect(controller.controllers[0]?.animation.value, 0);
+ });
+ });
+
+ group('expand(...)', () {
+ test('does not call notifyListener on invalid index', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.expand(0);
+ expect(count, 0);
+
+ await controller.expand(1);
+ expect(count, 0);
+ });
+
+ test('aware of max limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.addItem(1, animationControllers[1], animations[1], initiallyExpanded: false);
+ await controller.expand(1);
+ expect(controller.expanded, {1});
+ });
+ });
+
+ group('collapse(...)', () {
+ test('does not call notifyListener on invalid index', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: false);
+ await controller.collapse(0);
+ expect(count, 0);
+ });
+
+ test('aware of min limit', () async {
+ await controller.addItem(0, animationControllers[0], animations[0], initiallyExpanded: true);
+ await controller.collapse(0);
+ expect(controller.expanded.isEmpty, true);
+ });
+ });
+
+ test('validate(...)', () {
+ expect(controller.validate(0), true);
+ expect(controller.validate(1), true);
+ expect(controller.validate(2), false);
+ });
+ });
+}
diff --git a/forui/test/src/widgets/accordion/accordion_golden_test.dart b/forui/test/src/widgets/accordion/accordion_golden_test.dart
new file mode 100644
index 000000000..04cf071c5
--- /dev/null
+++ b/forui/test/src/widgets/accordion/accordion_golden_test.dart
@@ -0,0 +1,74 @@
+@Tags(['golden'])
+library;
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ group('FAccordion', () {
+ testWidgets('shown', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ data: FThemes.zinc.light,
+ child: const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FAccordion(
+ children: [
+ FAccordionItem(
+ title: Text('Title'),
+ initiallyExpanded: true,
+ child: ColoredBox(
+ color: Colors.yellow,
+ child: SizedBox.square(
+ dimension: 50,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('accordion/shown.png'));
+ });
+
+ testWidgets('hidden', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ data: FThemes.zinc.light,
+ child: const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FAccordion(
+ children: [
+ FAccordionItem(
+ title: Text('Title'),
+ initiallyExpanded: true,
+ child: ColoredBox(
+ color: Colors.yellow,
+ child: SizedBox.square(
+ dimension: 50,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Title'));
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('accordion/hidden.png'));
+ });
+ });
+}
diff --git a/forui/test/src/widgets/accordion/accordion_test.dart b/forui/test/src/widgets/accordion/accordion_test.dart
new file mode 100644
index 000000000..ec2499681
--- /dev/null
+++ b/forui/test/src/widgets/accordion/accordion_test.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ group('FAccordion', () {
+ testWidgets('hit test', (tester) async {
+ var taps = 0;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: TestScaffold(
+ data: FThemes.zinc.light,
+ child: FAccordion(
+ children: [
+ FAccordionItem(
+ title: const Text('Title'),
+ initiallyExpanded: true,
+ child: SizedBox.square(
+ dimension: 1,
+ child: GestureDetector(
+ onTap: () => taps++,
+ child: const Text('button'),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Title'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('button'), warnIfMissed: false);
+ expect(taps, 0);
+
+ await tester.tap(find.text('Title'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('button'));
+ expect(taps, 1);
+ });
+ });
+}
diff --git a/samples/lib/main.dart b/samples/lib/main.dart
index 31eda17ba..708d19c20 100644
--- a/samples/lib/main.dart
+++ b/samples/lib/main.dart
@@ -36,6 +36,7 @@ class _AppRouter extends RootStackRouter {
@override
List get routes => [
AutoRoute(page: EmptyRoute.page, initial: true),
+ AutoRoute(path: '/accordion/default', page: AccordionRoute.page),
AutoRoute(path: '/alert/default', page: AlertRoute.page),
AutoRoute(path: '/avatar/default', page: AvatarRoute.page),
AutoRoute(path: '/avatar/raw', page: AvatarRawRoute.page),
diff --git a/samples/lib/widgets/accordion.dart b/samples/lib/widgets/accordion.dart
new file mode 100644
index 000000000..3567e32c6
--- /dev/null
+++ b/samples/lib/widgets/accordion.dart
@@ -0,0 +1,51 @@
+import 'package:flutter/material.dart';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:forui/forui.dart';
+
+import 'package:forui_samples/sample_scaffold.dart';
+
+final controllers = {
+ 'default': FAccordionController(),
+ 'default-max': FAccordionController(max: 2),
+ 'radio': FAccordionController.radio(),
+};
+
+@RoutePage()
+class AccordionPage extends SampleScaffold {
+ final FAccordionController controller;
+
+ AccordionPage({
+ @queryParam super.theme,
+ @queryParam String controller = 'default',
+ }) : controller = controllers[controller] ?? FAccordionController();
+
+ @override
+ Widget child(BuildContext context) => Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FAccordion(
+ controller: controller,
+ children: const [
+ FAccordionItem(
+ title: Text('Is it accessible?'),
+ child: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
+ ),
+ FAccordionItem(
+ title: Text('Is it Styled?'),
+ initiallyExpanded: true,
+ child: Text(
+ "Yes. It comes with default styles that matches the other components' aesthetics",
+ ),
+ ),
+ FAccordionItem(
+ title: Text('Is it Animated?'),
+ child: Text(
+ 'Yes. It is animated by default, but you can disable it if you prefer',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+}