diff --git a/example/lib/pages/components/stepper_example.dart b/example/lib/pages/components/stepper_example.dart index ac7a2451..b1f8bd84 100644 --- a/example/lib/pages/components/stepper_example.dart +++ b/example/lib/pages/components/stepper_example.dart @@ -12,19 +12,9 @@ class StepperExample extends StatefulWidget { } class _StepperExampleState extends State { - int _sharpHorizontalStep = 0; + int _horistonalStep = 0; int _verticalStep = 0; - ZetaStepType _getForStepIndex({ - required int currentStep, - required int stepIndex, - }) { - if (currentStep == stepIndex) return ZetaStepType.enabled; - if (currentStep > stepIndex) return ZetaStepType.complete; - - return ZetaStepType.disabled; - } - @override Widget build(BuildContext context) { return ExampleScaffold( @@ -35,32 +25,17 @@ class _StepperExampleState extends State { SizedBox( height: 150, child: ZetaStepper( - currentStep: _sharpHorizontalStep, - onStepTapped: (index) => setState(() => _sharpHorizontalStep = index), + currentStep: _horistonalStep, + onStepTapped: (index) => setState(() => _horistonalStep = index), steps: [ ZetaStep( - type: _getForStepIndex( - currentStep: _sharpHorizontalStep, - stepIndex: 0, - ), title: Text("Title"), - content: Text("Content"), ), ZetaStep( - type: _getForStepIndex( - currentStep: _sharpHorizontalStep, - stepIndex: 1, - ), title: Text("Title 2"), - content: Text("Content 2"), ), ZetaStep( - type: _getForStepIndex( - currentStep: _sharpHorizontalStep, - stepIndex: 2, - ), title: Text("Title 3"), - content: Text("Content 3"), ), ], ), @@ -73,31 +48,17 @@ class _StepperExampleState extends State { onStepTapped: (index) => setState(() => _verticalStep = index), steps: [ ZetaStep( - type: _getForStepIndex( - currentStep: _verticalStep, - stepIndex: 0, - ), title: Text("Title"), subtitle: Text("Step Number"), - content: Text("Content"), ), ZetaStep( - type: _getForStepIndex( - currentStep: _verticalStep, - stepIndex: 1, - ), title: Text("Title 2"), subtitle: Text("Step Number"), - content: Text("Content 2"), + disabled: true, ), ZetaStep( - type: _getForStepIndex( - currentStep: _verticalStep, - stepIndex: 2, - ), title: Text("Title 3"), subtitle: Text("Step Number"), - content: Text("Content 3"), ), ], ), diff --git a/example/widgetbook/pages/components/stepper_widgetbook.dart b/example/widgetbook/pages/components/stepper_widgetbook.dart index 6744143f..082e4590 100644 --- a/example/widgetbook/pages/components/stepper_widgetbook.dart +++ b/example/widgetbook/pages/components/stepper_widgetbook.dart @@ -7,13 +7,6 @@ import '../../utils/scaffold.dart'; Widget stepperUseCase(BuildContext context) { int currentStep = 0; - ZetaStepType getForStepIndex(int stepIndex) { - if (currentStep == stepIndex) return ZetaStepType.enabled; - if (currentStep > stepIndex) return ZetaStepType.complete; - - return ZetaStepType.disabled; - } - final type = context.knobs.list( label: "Type", options: [ @@ -24,8 +17,6 @@ Widget stepperUseCase(BuildContext context) { labelBuilder: (type) => type.name, ); - final enabledContent = context.knobs.boolean(label: 'Enabled Content', initialValue: true); - return WidgetbookScaffold( builder: (context, _) => StatefulBuilder( builder: (context, setState) { @@ -40,19 +31,13 @@ Widget stepperUseCase(BuildContext context) { type: type, steps: [ ZetaStep( - type: getForStepIndex(0), title: Text("Title"), - content: enabledContent ? Text("Content") : null, ), ZetaStep( - type: getForStepIndex(1), title: Text("Title 2"), - content: enabledContent ? Text("Content 2") : null, ), ZetaStep( - type: getForStepIndex(2), title: Text("Title 3"), - content: enabledContent ? Text("Content 3") : null, ), ], ), diff --git a/lib/src/components/components.dart b/lib/src/components/components.dart index ab887229..b1e5eb25 100644 --- a/lib/src/components/components.dart +++ b/lib/src/components/components.dart @@ -45,7 +45,7 @@ export 'segmented_control/segmented_control.dart'; export 'select_input/select_input.dart'; export 'slider/slider.dart'; export 'snack_bar/snack_bar.dart'; -export 'stepper/stepper.dart'; +export 'stepper/stepper.dart' hide HorizontalStep, StepDivider, StepIcon, VerticalStep; export 'stepper_input/stepper_input.dart' hide ZetaStepperInputState; export 'switch/zeta_switch.dart'; export 'tabs/tab.dart'; diff --git a/lib/src/components/stepper/stepper.dart b/lib/src/components/stepper/stepper.dart index b118d81f..069f8307 100644 --- a/lib/src/components/stepper/stepper.dart +++ b/lib/src/components/stepper/stepper.dart @@ -6,6 +6,26 @@ import '../../../zeta_flutter.dart'; /// steps. Steppers are particularly useful in the case of forms where one step /// requires the completion of another one, or where multiple steps need to be /// completed in order to submit the whole form. +/// +/// The steppers current step is managed through the [currentStep] property. +/// To change it, store this value in state and change it with the [onStepTapped] callback. +/// The stored value can then be used to update content depending on the selected step. +/// +/// ```dart +/// ZetaStepper( +/// steps: [ +/// ZetaStep(title: Text('Step 1')), +/// ZetaStep(title: Text('Step 2')), +/// ZetaStep(title: Text('Step 3')), +/// ], +/// currentStep: currentStep, +/// onStepTapped: (step) { +/// setState(() { +/// currentStep = step; +/// }); +/// }, +/// ) +/// ``` /// {@category Components} /// /// Figma: https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=3420-67488&node-type=canvas&m=dev @@ -52,325 +72,418 @@ class ZetaStepper extends ZetaStatefulWidget { super.debugFillProperties(properties); properties ..add(IterableProperty('steps', steps)) - ..properties.add(IntProperty('currentStep', currentStep)) + ..add(IntProperty('currentStep', currentStep)) ..add(EnumProperty('type', type)) ..add( ObjectFlagProperty?>.has( 'onStepTapped', onStepTapped, ), - ) - ..properties.add(DiagnosticsProperty('rounded', rounded)); + ); } } class _ZetaStepperState extends State with TickerProviderStateMixin { - late List _keys; + bool _isLast(int index) { + return widget.steps.length - 1 == index; + } + + bool _isComplete(int index) { + return widget.currentStep > index; + } @override - void initState() { - super.initState(); - _keys = List.generate( - widget.steps.length, - (_) => GlobalKey(), + Widget build(BuildContext context) { + return ZetaRoundedScope( + rounded: context.rounded, + child: switch (widget.type) { + ZetaStepperType.vertical => IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.steps + .map( + (step) => VerticalStep( + step: step, + index: widget.steps.indexOf(step), + completed: _isComplete(widget.steps.indexOf(step)), + isLast: _isLast(widget.steps.indexOf(step)), + onStepTapped: !step.disabled ? () => widget.onStepTapped?.call(widget.steps.indexOf(step)) : null, + ), + ) + .toList(), + ), + ), + ZetaStepperType.horizontal => Material( + color: Colors.transparent, + child: Container( + margin: EdgeInsets.symmetric(horizontal: Zeta.of(context).spacing.xl_2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final step in widget.steps) ...[ + HorizontalStep( + step: step, + index: widget.steps.indexOf(step), + completed: _isComplete(widget.steps.indexOf(step)), + onStepTapped: !step.disabled ? () => widget.onStepTapped?.call(widget.steps.indexOf(step)) : null, + ), + if (!_isLast(widget.steps.indexOf(step))) + Expanded( + child: StepDivider( + disabled: step.disabled, + type: ZetaStepperType.horizontal, + completed: _isComplete(widget.steps.indexOf(step)), + ), + ), + ], + ], + ), + ), + ) + }, ); } +} - ZetaColors get _colors => Zeta.of(context).colors; +Color _getElementColor(BuildContext context, bool disabled, bool completed) { + final colors = Zeta.of(context).colors; + Color boxColor = colors.primary; - bool _isFirst(int index) { - return index == 0; + if (disabled) { + boxColor = colors.iconDisabled; + } else if (completed) { + boxColor = colors.surfacePositive; } - bool _isLast(int index) { - return widget.steps.length - 1 == index; - } + return boxColor; +} - bool _isCurrent(int index) { - return widget.currentStep == index; - } +/// The icon that represents a step in the [ZetaStepper] widget. +@visibleForTesting +@protected +class StepIcon extends StatelessWidget { + /// Creates a step icon for the [ZetaStepper] widget. + const StepIcon({ + required this.index, + required this.completed, + required this.disabled, + required this.type, + super.key, + }); + + /// The index of the step in the list of steps. + final int index; - Widget _buildHorizontalIcon(int index) { + /// Whether the step is completed. + final bool completed; + + /// Whether the step is disabled. + final bool disabled; + + /// The size of the icon. + final ZetaStepperType type; + + @override + Widget build(BuildContext context) { final rounded = context.rounded; + final colors = Zeta.of(context).colors; + final spacing = Zeta.of(context).spacing; - return SizedBox( - width: Zeta.of(context).spacing.xl_4, - height: Zeta.of(context).spacing.xl_4, - child: AnimatedContainer( - curve: Curves.fastOutSlowIn, - duration: kThemeAnimationDuration, - decoration: BoxDecoration( - color: _getColorForType(widget.steps[index].type), - shape: rounded ? BoxShape.circle : BoxShape.rectangle, - ), - child: Center( - child: switch (widget.steps[index].type) { - ZetaStepType.complete => ZetaIcon( + final size = switch (type) { + ZetaStepperType.horizontal => spacing.xl_4, + ZetaStepperType.vertical => spacing.xl_6, + }; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: _getElementColor(context, disabled, completed), + shape: rounded ? BoxShape.circle : BoxShape.rectangle, + ), + child: Center( + child: completed && !disabled + ? ZetaIcon( ZetaIcons.check_mark, - color: _colors.textInverse, - ), - ZetaStepType.enabled || ZetaStepType.disabled => Text( + color: colors.textInverse, + ) + : Text( (index + 1).toString(), style: ZetaTextStyles.labelLarge.copyWith( - color: _colors.textInverse, + color: colors.textInverse, ), ), - }, - ), ), ); } - Widget _getVerticalIcon(int index) { - return SizedBox( - width: Zeta.of(context).spacing.xl_8, - height: Zeta.of(context).spacing.xl_8, - child: AnimatedContainer( - curve: Curves.fastOutSlowIn, - duration: kThemeAnimationDuration, - decoration: BoxDecoration( - color: _getColorForType(widget.steps[index].type), - shape: context.rounded ? BoxShape.circle : BoxShape.rectangle, - ), - child: Center( - child: switch (widget.steps[index].type) { - ZetaStepType.complete => ZetaIcon( - ZetaIcons.check_mark, - color: _colors.textInverse, - ), - ZetaStepType.enabled || ZetaStepType.disabled => Text( - (index + 1).toString(), - style: ZetaTextStyles.titleLarge.copyWith( - color: _colors.textInverse, - ), - ), - }, - ), + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IntProperty('index', index)) + ..add(DiagnosticsProperty('completed', completed)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(EnumProperty('type', type)); + } +} + +/// A divider that separates steps in the [ZetaStepper] widget. +@visibleForTesting +@protected +class StepDivider extends StatelessWidget { + /// Creates a step divider for the [ZetaStepper] widget. + const StepDivider({ + super.key, + required this.type, + required this.disabled, + required this.completed, + }); + + /// Disables the divider and changes its color. + final bool disabled; + + /// Changes the color of the divider to indicate completion. + final bool completed; + + /// The type of the divider. + final ZetaStepperType type; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final spacing = Zeta.of(context).spacing; + + Color color = colors.borderPrimary; + + if (disabled) { + color = colors.borderDefault; + } else if (completed) { + color = colors.borderPositive; + } + + return Container( + margin: switch (type) { + ZetaStepperType.horizontal => EdgeInsets.only( + top: spacing.xl_3, + right: spacing.small, + left: spacing.small, + ), + ZetaStepperType.vertical => EdgeInsets.only( + top: spacing.minimum, + ), + }, + width: switch (type) { + ZetaStepperType.horizontal => double.infinity, + ZetaStepperType.vertical => spacing.minimum, + }, + height: switch (type) { + ZetaStepperType.horizontal => ZetaBorders.medium, + ZetaStepperType.vertical => spacing.xl_8, + }, + decoration: BoxDecoration( + borderRadius: Zeta.of(context).radius.full, + color: color, ), ); } - Widget _getHeaderText(int index) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedDefaultTextStyle( - style: switch (widget.steps[index].type) { - ZetaStepType.enabled || ZetaStepType.complete => ZetaTextStyles.bodySmall.copyWith( - color: _colors.textDefault, - ), - ZetaStepType.disabled => ZetaTextStyles.bodySmall.copyWith( - color: _colors.textDisabled, - ), - }, - maxLines: 1, - duration: kThemeAnimationDuration, - curve: Curves.fastOutSlowIn, - child: widget.steps[index].title, - ), - ], - ); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('completed', completed)) + ..add(EnumProperty('type', type)); } +} - Widget _getVerticalHeader(int index) { - final subtitle = widget.steps[index].subtitle; +/// A horizontal step in the [ZetaStepper] widget. +@visibleForTesting +@protected +class HorizontalStep extends StatelessWidget { + /// Creates a horizontal step in the [ZetaStepper] widget. + const HorizontalStep({ + required this.step, + required this.index, + required this.completed, + this.onStepTapped, + super.key, + }); - return Container( - margin: EdgeInsets.only(top: _isFirst(index) ? 0.0 : Zeta.of(context).spacing.xl_2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( + /// The step that this widget represents. + final ZetaStep step; + + /// The index of the step in the list of steps. + final int index; + + /// Whether the step is completed. + final bool completed; + + /// The callback called when the step is tapped. + final VoidCallback? onStepTapped; + + @override + Widget build(BuildContext context) { + final spacing = Zeta.of(context).spacing; + final colors = Zeta.of(context).colors; + final radius = Zeta.of(context).radius; + + return Semantics( + label: step.semanticLabel, + excludeSemantics: step.semanticLabel != null, + child: InkWell( + onTap: onStepTapped, + canRequestFocus: !step.disabled, + borderRadius: radius.minimal, + child: Padding( + padding: EdgeInsets.only(left: spacing.small, right: spacing.small, bottom: spacing.small), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - _getVerticalIcon(index), - Container( - margin: EdgeInsets.only(top: Zeta.of(context).spacing.minimum), - width: Zeta.of(context).spacing.minimum, - height: Zeta.of(context).spacing.xl_8, - decoration: BoxDecoration( - borderRadius: Zeta.of(context).radius.full, - color: switch (widget.steps[index].type) { - ZetaStepType.complete => _colors.green.shade50, - ZetaStepType.disabled => _colors.borderSubtle, - ZetaStepType.enabled => _colors.blue.shade50, - }, + Center( + child: Padding( + padding: EdgeInsets.symmetric( + vertical: spacing.medium, + ), + child: StepIcon( + index: index, + completed: completed, + disabled: step.disabled, + type: ZetaStepperType.horizontal, + ), ), ), - ], - ), - Expanded( - child: Container( - margin: EdgeInsets.symmetric(horizontal: Zeta.of(context).spacing.xl_2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (subtitle != null) - AnimatedDefaultTextStyle( - style: ZetaTextStyles.bodyMedium.copyWith( - color: _getColorForType(widget.steps[index].type), - ), - maxLines: 1, - duration: kThemeAnimationDuration, - curve: Curves.fastOutSlowIn, - child: subtitle, - ), - AnimatedDefaultTextStyle( - style: switch (widget.steps[index].type) { - ZetaStepType.enabled || ZetaStepType.complete => ZetaTextStyles.titleLarge.copyWith( - color: _colors.textDefault, - ), - ZetaStepType.disabled => ZetaTextStyles.titleLarge.copyWith( - color: _colors.textDisabled, - ), - }, - maxLines: 1, - duration: kThemeAnimationDuration, - curve: Curves.fastOutSlowIn, - child: widget.steps[index].title, - ), - ], + DefaultTextStyle( + style: ZetaTextStyles.bodySmall.copyWith( + color: step.disabled ? colors.textDisabled : colors.textDefault, + ), + child: step.title, ), - ), + ], ), - ], + ), ), ); } - Widget _getVerticalBody(int index) { - return Stack( - children: [ - AnimatedCrossFade( - firstChild: Container(height: 0), - secondChild: widget.steps[index].content ?? const Nothing(), - firstCurve: const Interval(0, 0.6, curve: Curves.fastOutSlowIn), - secondCurve: const Interval(0.4, 1, curve: Curves.fastOutSlowIn), - sizeCurve: Curves.fastOutSlowIn, - crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst, - duration: kThemeAnimationDuration, - ), - ], - ); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('step', step)) + ..add(IntProperty('index', index)) + ..add(DiagnosticsProperty('completed', completed)) + ..add(ObjectFlagProperty.has('onStepTapped', onStepTapped)); } +} - Color _getColorForType(ZetaStepType type) { - return switch (type) { - ZetaStepType.complete => _colors.surfacePositive, - ZetaStepType.disabled => _colors.cool.shade50, - ZetaStepType.enabled => _colors.primary, - }; - } +/// A vertical step in the [ZetaStepper] widget. +@visibleForTesting +@protected +class VerticalStep extends StatelessWidget { + /// Creates a vertical step in the [ZetaStepper] widget. + const VerticalStep({ + required this.step, + required this.index, + required this.completed, + required this.isLast, + this.onStepTapped, + super.key, + }); + + /// The step that this widget represents. + final ZetaStep step; + + /// The index of the step in the list of steps. + final int index; + + /// Whether the step is completed. + final bool completed; + + /// Whether the step is the last one in the list. + final bool isLast; + + /// The callback called when the step is tapped. + final VoidCallback? onStepTapped; @override Widget build(BuildContext context) { - return ZetaRoundedScope( - rounded: context.rounded, - child: switch (widget.type) { - ZetaStepperType.vertical => Column( - children: [ - for (int index = 0; index < widget.steps.length; index += 1) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - key: _keys[index], - children: [ - InkResponse( - containedInkWell: true, - borderRadius: Zeta.of(context).radius.minimal, - onTap: widget.onStepTapped != null ? () => widget.onStepTapped?.call(index) : null, - canRequestFocus: widget.steps[index].type != ZetaStepType.disabled, - child: _getVerticalHeader(index), - ), - _getVerticalBody(index), - ], - ), - ], + final spacing = Zeta.of(context).spacing; + final colors = Zeta.of(context).colors; + + return Semantics( + label: step.semanticLabel, + excludeSemantics: step.semanticLabel != null, + child: InkWell( + borderRadius: Zeta.of(context).radius.minimal, + onTap: onStepTapped, + canRequestFocus: !step.disabled, + child: Container( + padding: EdgeInsets.all( + spacing.medium, ), - ZetaStepperType.horizontal => Builder( - builder: (context) { - final children = [ - for (int index = 0; index < widget.steps.length; index += 1) ...[ - InkResponse( - onTap: widget.onStepTapped != null ? () => widget.onStepTapped?.call(index) : null, - canRequestFocus: widget.steps[index].type != ZetaStepType.disabled, - child: Column( - children: [ - Center( - child: Padding( - padding: EdgeInsets.symmetric( - vertical: Zeta.of(context).spacing.medium, - ), - child: _buildHorizontalIcon(index), - ), - ), - _getHeaderText(index), - ], - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + StepIcon( + index: index, + completed: completed, + disabled: step.disabled, + type: ZetaStepperType.vertical, ), - if (!_isLast(index)) - Expanded( - child: Container( - key: Key('line$index'), - margin: EdgeInsets.only( - top: Zeta.of(context).spacing.xl_3, - right: Zeta.of(context).spacing.large, - left: Zeta.of(context).spacing.large, - ), - height: ZetaBorders.medium, - decoration: BoxDecoration( - borderRadius: Zeta.of(context).radius.full, - color: switch (widget.steps[index].type) { - ZetaStepType.complete => _colors.green.shade50, - ZetaStepType.disabled => _colors.borderSubtle, - ZetaStepType.enabled => _colors.blue.shade50, - }, - ), - ), + if (!isLast) + StepDivider( + type: ZetaStepperType.vertical, + completed: completed, + disabled: step.disabled, ), ], - ]; - - final List stepPanels = []; - for (int i = 0; i < widget.steps.length; i += 1) { - stepPanels.add( - Visibility( - maintainState: true, - visible: i == widget.currentStep, - child: widget.steps[i].content ?? const Nothing(), - ), - ); - } - - return Column( + ), + SizedBox(width: spacing.xl_2), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Material( - color: Colors.transparent, - child: Container( - margin: EdgeInsets.symmetric(horizontal: Zeta.of(context).spacing.xl_2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: children, + if (step.subtitle != null) + AnimatedDefaultTextStyle( + style: ZetaTextStyles.bodyMedium.copyWith( + color: _getElementColor(context, step.disabled, completed), ), - ), - ), - Expanded( - child: AnimatedSize( - curve: Curves.fastOutSlowIn, + maxLines: 1, duration: kThemeAnimationDuration, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: stepPanels, - ), + curve: Curves.fastOutSlowIn, + child: step.subtitle!, + ), + DefaultTextStyle( + style: ZetaTextStyles.titleLarge.copyWith( + color: step.disabled ? colors.textDisabled : colors.textDefault, ), + maxLines: 1, + child: step.title, ), ], - ); - }, + ), + ], ), - }, + ), + ), ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('step', step)) + ..add(IntProperty('index', index)) + ..add(DiagnosticsProperty('completed', completed)) + ..add(DiagnosticsProperty('isLast', isLast)) + ..add(ObjectFlagProperty.has('onStepTapped', onStepTapped)); + } } /// Zeta step used in [ZetaStepper]. The step can have a title and subtitle, @@ -380,12 +493,18 @@ class ZetaStep { /// Creates a step for a [ZetaStepper]. const ZetaStep({ required this.title, - this.content, + @Deprecated('Steps no longer manage their own content. ' 'Deprecated as of 0.16.1') this.content, this.subtitle, + this.disabled = false, + this.semanticLabel, + @Deprecated( + 'To disable a step, set its disabled prop to true. To complete a step, set the currentStep prop on the stepper greater than the step index. ' + 'Deprecated as of 0.16.1') this.type = ZetaStepType.disabled, }); /// The content of the step that appears below the [title] and [subtitle]. + @Deprecated('Steps no longer manage their own content. ' 'Deprecated as of 0.16.1') final Widget? content; /// The subtitle of the step that appears above the title. @@ -394,12 +513,24 @@ class ZetaStep { /// The title of the step that typically describes it. final Widget title; + /// The semantic label of the step that is read by screen readers. + final String? semanticLabel; + + /// Whether the step is disabled and does not react to taps. + final bool disabled; + /// The type of the step which determines the styling of its components /// and whether steps are interactive. + @Deprecated( + 'To disable a step, set its disabled prop to true. To complete a step, set the activeStep prop on the stepper greater than the step index. ' + 'Deprecated as of 0.16.1') final ZetaStepType type; } /// The type of a [ZetaStep] which is used to control the style of the circle and text. +@Deprecated( + 'To disable a step, set its disabled prop to true. To complete a step, set the activeStep prop on the stepper greater than the step index. ' + 'Deprecated as of 0.16.1') enum ZetaStepType { /// A step that is currently selected with primary color icon enabled, diff --git a/test/TESTING_README.md b/test/TESTING_README.md index a98b8042..0bcb169e 100644 --- a/test/TESTING_README.md +++ b/test/TESTING_README.md @@ -59,6 +59,7 @@ void main() { }); group('Accessibility Tests', () {}); + group('Content Tests', () { final debugFillProperties = { '': '', @@ -68,12 +69,17 @@ void main() { debugFillProperties, ); }); + group('Dimensions Tests', () {}); + group('Styling Tests', () {}); + group('Interaction Tests', () {}); + group('Golden Tests', () { goldenTest(goldenFile, widget, widgetType, 'PNG_FILE_NAME'); }); + group('Performance Tests', () {}); } ``` diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_complete.png b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_complete.png new file mode 100644 index 00000000..23ee538b Binary files /dev/null and b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_complete.png differ diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_incomplete.png b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_incomplete.png new file mode 100644 index 00000000..11ed680d Binary files /dev/null and b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_incomplete.png differ diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_step_disabled.png b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_step_disabled.png new file mode 100644 index 00000000..6836df41 Binary files /dev/null and b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_step_disabled.png differ diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_complete.png b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_complete.png new file mode 100644 index 00000000..e8b69769 Binary files /dev/null and b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_complete.png differ diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_incomplete.png b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_incomplete.png new file mode 100644 index 00000000..de3b2b70 Binary files /dev/null and b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_incomplete.png differ diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_step_disabled.png b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_step_disabled.png new file mode 100644 index 00000000..a27232f1 Binary files /dev/null and b/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_step_disabled.png differ diff --git a/test/src/components/stepper/stepper_test.dart b/test/src/components/stepper/stepper_test.dart new file mode 100644 index 00000000..f8341f0c --- /dev/null +++ b/test/src/components/stepper/stepper_test.dart @@ -0,0 +1,532 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/src/components/stepper/stepper.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_app.dart'; +import '../../../test_utils/tolerant_comparator.dart'; +import '../../../test_utils/utils.dart'; + +void main() { + const String parentFolder = 'ENTER_PARENT_FOLDER (e.g. button)'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + group('ZetaStepper Accessibility Tests', () { + testWidgets('Horizontal stepper meets accessibility requirements', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ZetaStep(title: Text('Title'))], + currentStep: 0, + onStepTapped: (step) {}, + ), + ), + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + + testWidgets('Vertical stepper meets accessibility requirements', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ZetaStep(title: Text('Title'))], + currentStep: 0, + type: ZetaStepperType.vertical, + onStepTapped: (step) {}, + ), + ), + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + + testWidgets('Horizontal steps correctly recieve semantic labels', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ZetaStep(title: Text('Title'), semanticLabel: 'semantic label')], + currentStep: 0, + onStepTapped: (step) {}, + ), + ), + ); + + expect(find.bySemanticsLabel('semantic label'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('Vertical steps correctly recieve semantic labels', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ZetaStep(title: Text('Title'), semanticLabel: 'semantic label')], + currentStep: 0, + type: ZetaStepperType.vertical, + onStepTapped: (step) {}, + ), + ), + ); + + expect(find.bySemanticsLabel('semantic label'), findsOneWidget); + + handle.dispose(); + }); + }); + + group('ZetaStepper Content Tests', () { + testWidgets('Horizontal stepper renders the correct steps', (WidgetTester tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 0, + onStepTapped: (step) {}, + ), + ), + ); + + expect(find.text('Title 1'), findsOneWidget); + expect(find.text('Title 2'), findsOneWidget); + expect(find.text('Title 3'), findsOneWidget); + }); + + testWidgets('Vertical stepper renders the correct steps', (WidgetTester tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + type: ZetaStepperType.vertical, + currentStep: 0, + onStepTapped: (step) {}, + ), + ), + ); + + expect(find.text('Title 1'), findsOneWidget); + expect(find.text('Title 2'), findsOneWidget); + expect(find.text('Title 3'), findsOneWidget); + }); + + testWidgets('StepIcon displays the correct text', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: false, + disabled: false, + index: 0, + type: ZetaStepperType.horizontal, + ), + ), + ); + + expect(find.text('1'), findsOneWidget); + }); + testWidgets('StepIcon displays the correct icon when completed', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: true, + disabled: false, + index: 0, + type: ZetaStepperType.horizontal, + ), + ), + ); + + expect(find.byIcon(ZetaIcons.check_mark_round), findsOneWidget); + }); + + debugFillPropertiesTest( + const ZetaStepper(steps: [], currentStep: 0), + { + 'steps': '[]', + 'currentStep': '0', + 'type': 'horizontal', + 'onStepTapped': 'null', + }, + ); + debugFillPropertiesTest( + const StepIcon(completed: false, disabled: false, index: 0, type: ZetaStepperType.horizontal), + { + 'index': '0', + 'type': 'horizontal', + 'completed': 'false', + 'disabled': 'false', + }, + ); + const step = ZetaStep(title: Text('Title')); + debugFillPropertiesTest( + const HorizontalStep(step: step, index: 0, completed: false), + { + 'step': "Instance of 'ZetaStep'", + 'index': '0', + 'completed': 'false', + 'onStepTapped': 'null', + }, + ); + debugFillPropertiesTest( + const VerticalStep(step: step, index: 0, completed: false, isLast: false), + { + 'step': "Instance of 'ZetaStep'", + 'index': '0', + 'completed': 'false', + 'isLast': 'false', + 'onStepTapped': 'null', + }, + ); + }); + + group('ZetaStepper Dimensions Tests', () { + testWidgets('StepIcon horiztonal has the correct size', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: false, + disabled: false, + index: 0, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + final spacing = Zeta.of(getBuildContext(tester, Container)).spacing; + + expect(container.constraints?.maxHeight, spacing.xl_4); + expect(container.constraints?.maxWidth, spacing.xl_4); + }); + + testWidgets('StepIcon vertical has the correct size', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: false, + disabled: false, + index: 0, + type: ZetaStepperType.vertical, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + final spacing = Zeta.of(getBuildContext(tester, Container)).spacing; + + expect(container.constraints?.maxHeight, spacing.xl_6); + expect(container.constraints?.maxWidth, spacing.xl_6); + }); + + testWidgets('StepDivider horizontal has the correct size', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepDivider( + completed: false, + disabled: false, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect(container.constraints?.maxHeight, ZetaBorders.medium); + expect(container.constraints?.maxWidth, double.infinity); + }); + + testWidgets('StepDivider vertical has the correct size', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepDivider( + completed: false, + disabled: false, + type: ZetaStepperType.vertical, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + final spacing = Zeta.of(getBuildContext(tester, Container)).spacing; + expect(container.constraints?.maxHeight, spacing.xl_8); + expect(container.constraints?.maxWidth, spacing.minimum); + }); + }); + + group('ZetaStepper Styling Tests', () { + testWidgets( + 'StepIcon has the correct colour when enabled', + (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: false, + disabled: false, + index: 0, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect( + (container.decoration! as BoxDecoration).color, + Zeta.of(getBuildContext(tester, Container)).colors.primary, + ); + }, + ); + testWidgets( + 'StepIcon has the correct colour when completed', + (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: true, + disabled: false, + index: 0, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect( + (container.decoration! as BoxDecoration).color, + Zeta.of(getBuildContext(tester, Container)).colors.surfacePositive, + ); + }, + ); + testWidgets( + 'StepIcon has the correct colour when disabled', + (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepIcon( + completed: true, + disabled: true, + index: 0, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect( + (container.decoration! as BoxDecoration).color, + Zeta.of(getBuildContext(tester, Container)).colors.iconDisabled, + ); + }, + ); + testWidgets( + 'StepDivider has the correct colour when enabled', + (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepDivider( + completed: false, + disabled: false, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect( + (container.decoration! as BoxDecoration).color, + Zeta.of(getBuildContext(tester, Container)).colors.borderPrimary, + ); + }, + ); + testWidgets( + 'StepDivider has the correct colour when completed', + (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepDivider( + completed: true, + disabled: false, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect( + (container.decoration! as BoxDecoration).color, + Zeta.of(getBuildContext(tester, Container)).colors.borderPositive, + ); + }, + ); + testWidgets( + 'StepDivider has the correct colour when disabled', + (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: StepDivider( + completed: true, + disabled: true, + type: ZetaStepperType.horizontal, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect( + (container.decoration! as BoxDecoration).color, + Zeta.of(getBuildContext(tester, Container)).colors.borderDefault, + ); + }, + ); + }); + + group('ZetaStepper Interaction Tests', () { + testWidgets('Horizontal stepper calls onStepTapped when a step is tapped', (WidgetTester tester) async { + int tappedStep = -1; + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 0, + onStepTapped: (step) { + tappedStep = step; + }, + ), + ), + ); + + await tester.tap(find.text('Title 2')); + expect(tappedStep, 1); + }); + + testWidgets('Vertical stepper calls onStepTapped when a step is tapped', (WidgetTester tester) async { + int tappedStep = -1; + await tester.pumpWidget( + TestApp( + home: ZetaStepper( + steps: const [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + type: ZetaStepperType.vertical, + currentStep: 0, + onStepTapped: (step) { + tappedStep = step; + }, + ), + ), + ); + + await tester.tap(find.text('Title 2')); + expect(tappedStep, 1); + }); + }); + + group('ZetaStepper Golden Tests', () { + goldenTest( + goldenFile, + const ZetaStepper( + steps: [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 0, + ), + 'stepper_horizontal_incomplete', + ); + + goldenTest( + goldenFile, + const ZetaStepper( + steps: [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 4, + ), + 'stepper_horizontal_complete', + ); + goldenTest( + goldenFile, + const ZetaStepper( + steps: [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2'), disabled: true), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 0, + ), + 'stepper_horizontal_step_disabled', + ); + goldenTest( + goldenFile, + const ZetaStepper( + type: ZetaStepperType.vertical, + steps: [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 0, + ), + 'stepper_vertical_incomplete', + ); + + goldenTest( + goldenFile, + const ZetaStepper( + type: ZetaStepperType.vertical, + steps: [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2')), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 4, + ), + 'stepper_vertical_complete', + ); + goldenTest( + goldenFile, + const ZetaStepper( + type: ZetaStepperType.vertical, + steps: [ + ZetaStep(title: Text('Title 1')), + ZetaStep(title: Text('Title 2'), disabled: true), + ZetaStep(title: Text('Title 3')), + ], + currentStep: 0, + ), + 'stepper_vertical_step_disabled', + ); + }); + + group('Performance Tests', () {}); +}