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', + ), + ), + ], + ), + ], + ); +}