diff --git a/example/lib/home.dart b/example/lib/home.dart index 0534fd37..a36d7091 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -6,7 +6,7 @@ import 'package:zeta_example/pages/components/avatar_example.dart'; import 'package:zeta_example/pages/components/badges_example.dart'; import 'package:zeta_example/pages/components/banner_example.dart'; import 'package:zeta_example/pages/components/bottom_sheet_example.dart'; -import 'package:zeta_example/pages/components/breadcrumbs_example.dart'; +import 'package:zeta_example/pages/components/breadcrumb_example.dart'; import 'package:zeta_example/pages/components/button_example.dart'; import 'package:zeta_example/pages/components/chat_item_example.dart'; import 'package:zeta_example/pages/components/checkbox_example.dart'; @@ -67,7 +67,7 @@ final List components = [ Component(BannerExample.name, (context) => const BannerExample()), Component(BadgesExample.name, (context) => const BadgesExample()), Component(BottomSheetExample.name, (context) => const BottomSheetExample()), - Component(BreadCrumbsExample.name, (context) => const BreadCrumbsExample()), + Component(BreadcrumbExample.name, (context) => const BreadcrumbExample()), Component(ButtonExample.name, (context) => const ButtonExample()), Component(ChatItemExample.name, (context) => const ChatItemExample()), Component(CheckBoxExample.name, (context) => const CheckBoxExample()), diff --git a/example/lib/pages/components/breadcrumb_example.dart b/example/lib/pages/components/breadcrumb_example.dart new file mode 100644 index 00000000..948f0ceb --- /dev/null +++ b/example/lib/pages/components/breadcrumb_example.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class BreadcrumbExample extends StatefulWidget { + static const String name = 'Breadcrumb'; + + const BreadcrumbExample({super.key}); + + @override + State createState() => _BreadcrumbExampleState(); +} + +class _BreadcrumbExampleState extends State { + List _children = []; + + @override + void initState() { + super.initState(); + _children = [ + ZetaBreadcrumbItem(label: 'Breadcrumb', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 1', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 2', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 3', icon: ZetaIcons.star, onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 4', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 5', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 6', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 7', onPressed: () {}), + ]; + } + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: 'Breadcrumb', + child: Center( + child: SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + ZetaBreadcrumb(children: _children.sublist(0, 1)), + ZetaBreadcrumb(children: _children.sublist(0, 2)), + ZetaBreadcrumb(children: _children.sublist(0, 3)), + ZetaBreadcrumb(children: _children.sublist(0, 4), maxItemsShown: 3), + ZetaBreadcrumb(children: _children.sublist(0, 5), maxItemsShown: 5), + ZetaBreadcrumb(children: _children.sublist(0, 6), maxItemsShown: 3), + ZetaBreadcrumb(children: _children.sublist(0, 7)), + ZetaBreadcrumb(children: _children, maxItemsShown: 1), + ].gap(50), + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/pages/components/breadcrumbs_example.dart b/example/lib/pages/components/breadcrumbs_example.dart deleted file mode 100644 index b6e0c7fc..00000000 --- a/example/lib/pages/components/breadcrumbs_example.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:zeta_example/widgets.dart'; -import 'package:zeta_flutter/zeta_flutter.dart'; - -class BreadCrumbsExample extends StatefulWidget { - static const String name = 'Breadcrumbs'; - - const BreadCrumbsExample({super.key}); - - @override - State createState() => _BreadCrumbsExampleState(); -} - -class _BreadCrumbsExampleState extends State { - List _children = [ - ZetaBreadCrumb( - label: 'Icon before with separator', - onPressed: () { - print("Breadcrumb " + 0.toString() + "Clicked"); - }, - ), - ]; - int index = 1; - - @override - Widget build(BuildContext context) { - return ExampleScaffold( - name: 'Breadcrumbs', - child: Center( - child: SingleChildScrollView( - child: SizedBox( - width: double.infinity, - child: Column(children: [ - ZetaBreadCrumbs(children: _children), - SizedBox( - height: 50, - ), - FilledButton( - onPressed: () { - setState(() { - _children.add( - ZetaBreadCrumb( - label: 'Icon before with seperator', - onPressed: () { - print("Breadcrumb clicked"); - }, - ), - ); - index++; - }); - }, - child: Text("Add Breadcrumb")) - ])), - ), - ), - ); - } -} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 7eeba843..740cb129 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -16,7 +16,7 @@ import 'pages/components/avatar_widgetbook.dart'; import 'pages/components/badges_widgetbook.dart'; import 'pages/components/banner_widgetbook.dart'; import 'pages/components/bottom_sheet_widgetbook.dart'; -import 'pages/components/breadcrumbs_widgetbook.dart'; +import 'pages/components/breadcrumb_widgetbook.dart'; import 'pages/components/button_widgetbook.dart'; import 'pages/components/chat_item_widgetbook.dart'; import 'pages/components/checkbox_widgetbook.dart'; @@ -192,7 +192,7 @@ class _HotReloadState extends State { WidgetbookUseCase(name: 'Accordion', builder: (context) => accordionUseCase(context)), WidgetbookUseCase(name: 'Banners', builder: (context) => bannerUseCase(context)), WidgetbookUseCase(name: 'Bottom Sheet', builder: (context) => bottomSheetContentUseCase(context)), - WidgetbookUseCase(name: 'BreadCrumbs', builder: (context) => breadCrumbsUseCase(context)), + WidgetbookUseCase(name: 'Breadcrumb', builder: (context) => breadcrumbUseCase(context)), WidgetbookUseCase(name: 'Checkbox', builder: (context) => checkboxUseCase(context)), WidgetbookUseCase(name: 'Comms Button', builder: (context) => commsButtonUseCase(context)), WidgetbookUseCase(name: 'Date Input', builder: (context) => dateInputUseCase(context)), diff --git a/example/widgetbook/pages/components/breadcrumb_widgetbook.dart b/example/widgetbook/pages/components/breadcrumb_widgetbook.dart new file mode 100644 index 00000000..1dc8c1d4 --- /dev/null +++ b/example/widgetbook/pages/components/breadcrumb_widgetbook.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../utils/scaffold.dart'; +import 'package:widgetbook/widgetbook.dart'; + +final List children = [ + ZetaBreadcrumbItem(label: 'Breadcrumb', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 1', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 2', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 3', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 4', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 5', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 6', onPressed: () {}) +]; + +Widget breadcrumbUseCase(BuildContext context) => WidgetbookScaffold( + builder: (context, _) => Center( + child: BreadcrumbExample(context, children), + ), + ); + +class BreadcrumbExample extends StatelessWidget { + BreadcrumbExample(this.context, this.children); + final BuildContext context; + final List children; + + @override + Widget build(BuildContext _) { + return SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + ZetaBreadcrumb( + rounded: context.knobs.boolean(label: 'Rounded', initialValue: true), + children: context.knobs.list( + label: 'Items', + labelBuilder: (value) => value.length.toString(), + initialOption: children.sublist(0, 4), + options: List.generate( + children.length, + (index) => children.sublist(0, index + 1), + ), + ), + maxItemsShown: context.knobs.int.slider( + label: 'Max Items Shown', + initialValue: 2, + min: 1, + max: children.length, + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/pages/components/breadcrumbs_widgetbook.dart b/example/widgetbook/pages/components/breadcrumbs_widgetbook.dart deleted file mode 100644 index 63c99216..00000000 --- a/example/widgetbook/pages/components/breadcrumbs_widgetbook.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:zeta_flutter/zeta_flutter.dart'; - -import '../../utils/scaffold.dart'; -import '../../utils/utils.dart'; - -Widget breadCrumbsUseCase(BuildContext context) => WidgetbookScaffold( - builder: (context, _) => Center( - child: BreadCrumbExample(context), - ), - ); - -class BreadCrumbExample extends StatefulWidget { - const BreadCrumbExample(this.c); - final BuildContext c; - - @override - State createState() => _BreadCrumbExampleState(); -} - -class _BreadCrumbExampleState extends State { - List _children = [ - ZetaBreadCrumb( - label: 'Icon before with seperator', - onPressed: () { - print("Breadcrumb " + 0.toString() + "Clicked"); - }, - ), - ]; - int index = 1; - - @override - Widget build(BuildContext _) { - return SingleChildScrollView( - child: SizedBox( - width: double.infinity, - child: Column(children: [ - ZetaBreadCrumbs( - children: _children, - activeIcon: iconKnob(context), - ), - SizedBox( - height: 50, - ), - FilledButton( - onPressed: () { - setState(() { - _children.add( - ZetaBreadCrumb( - label: 'Icon before with seperator', - onPressed: () { - print("Breadcrumb clicked"); - }, - ), - ); - index++; - }); - }, - child: Text("Add Breadcrumb")) - ])), - ); - } -} diff --git a/lib/src/components/breadcrumb/breadcrumb.dart b/lib/src/components/breadcrumb/breadcrumb.dart new file mode 100644 index 00000000..f52f6184 --- /dev/null +++ b/lib/src/components/breadcrumb/breadcrumb.dart @@ -0,0 +1,367 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../zeta_flutter.dart'; + +/// The breadcrumb is a secondary navigation pattern that helps a user understand the hierarchy among levels and navigate back through them. +/// +/// [children] should consist of [ZetaBreadcrumbItem]s. +/// +/// {@category Components} +/// +/// Figma: https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=229-5&node-type=canvas&m=dev +/// +/// Widgetbook: https://zeta-ds.web.app/flutter/widgetbook/index.html#/?path=components/breadcrumbs +class ZetaBreadcrumb extends ZetaStatefulWidget { + ///Constructor for [ZetaBreadcrumb] + const ZetaBreadcrumb({ + super.key, + super.rounded, + required this.children, + this.moreSemanticLabel, + this.maxItemsShown = 2, + }); + + /// Breadcrumb children + final List children; + + /// Semantic label passed to [_TruncatedItem]. + /// {@macro zeta-widget-semantic-label} + final String? moreSemanticLabel; + + /// Maximum number of items shown in the breadcrumb that aren't truncated. + final int maxItemsShown; + + @override + State createState() => _ZetaBreadcrumbsState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty('children', children)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(StringProperty('moreSemanticLabel', moreSemanticLabel)) + ..add(IntProperty('maxItemsShown', maxItemsShown)); + } +} + +class _ZetaBreadcrumbsState extends State { + late int _selectedIndex; + late List _children; + + @override + void initState() { + super.initState(); + + _selectedIndex = widget.children.length - 1; + _children = [...widget.children]; + } + + @override + void didUpdateWidget(ZetaBreadcrumb oldWidget) { + super.didUpdateWidget(oldWidget); + + _selectedIndex = widget.children.length - 1; + _children = [...widget.children]; + } + + @override + Widget build(BuildContext context) { + final rounded = context.rounded; + return ZetaRoundedScope( + rounded: rounded, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: renderedChildren(_children) + .divide( + Row( + children: [ + SizedBox(width: Zeta.of(context).spacing.small), + ZetaIcon( + ZetaIcons.chevron_right, + size: Zeta.of(context).spacing.xl, + rounded: rounded, + color: Zeta.of(context).colors.textSubtle, + ), + SizedBox(width: Zeta.of(context).spacing.small), + ], + ), + ) + .toList(), + ), + ), + ); + } + + ///Creates breadcrumb widget + ZetaBreadcrumbItem createBreadCrumb(ZetaBreadcrumbItem input, int index) { + return ZetaBreadcrumbItem( + label: input.label, + isSelected: _selectedIndex == index, + icon: input.icon, + onPressed: () { + setState(() { + _selectedIndex = index; + _children = _children.sublist(0, index + 1); + }); + input.onPressed.call(); + }, + ); + } + + List renderedChildren(List children) { + final List returnList = []; + if (children.length > widget.maxItemsShown) { + returnList.add(createBreadCrumb(children.first, 0)); + + final List truncatedChildren = []; + + for (final (index, element) in children.sublist(1, children.length - (widget.maxItemsShown - 1)).indexed) { + truncatedChildren.add(createBreadCrumb(element, index + 1)); + } + returnList + .add(_TruncatedItem(semanticLabel: widget.moreSemanticLabel ?? 'View More', children: truncatedChildren)); + + for (final (index, element) in children.sublist(children.length - (widget.maxItemsShown - 1)).indexed) { + returnList.add(createBreadCrumb(element, index + children.length - (widget.maxItemsShown) + 1)); + } + } else { + for (final (index, element) in children.indexed) { + returnList.add(createBreadCrumb(element, index)); + } + } + return returnList; + } +} + +/// Class for untruncated [ZetaBreadcrumbItem]. +/// +/// Should be a child of [ZetaBreadcrumb]. +class ZetaBreadcrumbItem extends ZetaStatelessWidget { + ///Constructor for [ZetaBreadcrumbItem] + ZetaBreadcrumbItem({ + super.key, + super.rounded, + required this.label, + this.icon, + this.isSelected = false, + required this.onPressed, + this.semanticLabel, + }); + + /// [ZetaBreadcrumbItem] label. + final String label; + + /// Selected icon. + final IconData? icon; + + /// Is [ZetaBreadcrumbItem] selected. + final bool isSelected; + + /// Handles press for [ZetaBreadcrumbItem] + final VoidCallback onPressed; + + /// Label passed to wrapping [Semantics] widget. + /// + /// {@macro zeta-widget-semantic-label} + /// + /// If null, [label] is used. + final String? semanticLabel; + + // /// If true, the [ZetaBreadcrumbItem] icon will be rounded. + // final bool? rounded; + + /// Controller for [InkWell] states to control hover and pressed states. + final controller = WidgetStatesController(); + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final rounded = context.rounded; + return ZetaRoundedScope( + rounded: rounded, + child: Semantics( + label: semanticLabel ?? label, + selected: isSelected, + child: InkWell( + statesController: controller, + onTap: onPressed, + enableFeedback: false, + splashColor: Colors.transparent, + overlayColor: WidgetStateProperty.resolveWith((states) { + return Colors.transparent; + }), + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) => Row( + children: [ + if (icon != null) + ZetaIcon( + icon, + rounded: rounded, + color: getColor(value, colors), + ), + SizedBox( + width: Zeta.of(context).spacing.small, + ), + Text( + label, + style: ZetaTextStyles.bodySmall.apply(color: getColor(controller.value, colors)), + ), + if (child != null) child, + ], + ), + ), + ), + ), + ); + } + + /// Get color of breadcrumb based on state. + Color getColor(Set states, ZetaColors colors) { + if (states.contains(WidgetState.hovered)) { + return colors.blue; + } + if (isSelected) return colors.black; + return colors.textSubtle; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty( + 'controller', + controller, + ), + ) + ..add(StringProperty('label', label)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(DiagnosticsProperty('isSelected', isSelected)) + ..add(ObjectFlagProperty.has('onPressed', onPressed)) + ..add(StringProperty('semanticLabel', semanticLabel)) + ..add(DiagnosticsProperty('rounded', rounded)); + } +} + +/// Class for [_TruncatedItem] +@Deprecated('This functionality is not needed anymore. Use [ZetaBreadcrumb] instead. ' 'Deprecated since 0.14.1') +typedef TruncatedItem = _TruncatedItem; + +class _TruncatedItem extends StatefulWidget { + ///Constructor for [_TruncatedItem] + const _TruncatedItem({ + required this.children, + required this.semanticLabel, + }); + + ///Breadcrumb children + final List children; + + /// Label passed to wrapping [Semantics] widget. + /// + /// {@macro zeta-widget-semantic-label} + final String semanticLabel; + + @override + State<_TruncatedItem> createState() => _TruncatedItemState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('semanticLabel', semanticLabel)); + } +} + +class _TruncatedItemState extends State<_TruncatedItem> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final rounded = context.rounded; + + return _expanded + ? expandedBreadcrumb() + : ZetaRoundedScope( + rounded: rounded, + child: Semantics( + label: widget.semanticLabel, + button: true, + excludeSemantics: true, + child: FilledButton( + onPressed: () { + setState(() { + _expanded = true; + }); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return colors.surfaceHover; + } + if (states.contains(WidgetState.pressed)) { + return colors.primary.shade10; + } + if (states.contains(WidgetState.disabled)) { + return colors.surfaceDisabled; + } + return colors.warm.shade10; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.textDisabled; + } + return colors.textDefault; + }), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: (rounded ? Zeta.of(context).radius.minimal : Zeta.of(context).radius.none), + ), + ), + side: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return BorderSide( + width: ZetaBorders.medium, + color: colors.primary.shade100, + ); + } + if (states.isEmpty) { + return BorderSide(color: colors.borderDefault, width: 0.5); + } + return null; + }), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(Size.zero), + elevation: WidgetStatePropertyAll(Zeta.of(context).spacing.none), + ), + child: Icon( + rounded ? ZetaIcons.more_horizontal_round : ZetaIcons.more_horizontal_sharp, + size: Zeta.of(context).spacing.large, + ).paddingHorizontal(Zeta.of(context).spacing.small).paddingVertical(Zeta.of(context).spacing.minimum), + ), + ), + ); + } + + Widget expandedBreadcrumb() { + return Row( + children: widget.children + .divide( + Row( + children: [ + SizedBox(width: Zeta.of(context).spacing.small), + ZetaIcon( + ZetaIcons.chevron_right, + size: Zeta.of(context).spacing.xl, + color: Zeta.of(context).colors.textSubtle, + ), + SizedBox(width: Zeta.of(context).spacing.small), + ], + ), + ) + .toList(), + ); + } +} diff --git a/lib/src/components/breadcrumbs/breadcrumbs.dart b/lib/src/components/breadcrumbs/breadcrumbs.dart deleted file mode 100644 index 6f0f4aca..00000000 --- a/lib/src/components/breadcrumbs/breadcrumbs.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import '../../../zeta_flutter.dart'; - -// TODO(UX-1131): Refactor this to make BreadCrumbItem a class, not a widget. - -/// The breadcrumb is a secondary navigation pattern that helps a user understand the hierarchy among levels and navigate back through them. -/// -/// [children] should consist of [ZetaBreadCrumb]s. -/// -/// {@category Components} -/// -/// Figma: https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=229-5&node-type=canvas&m=dev -/// -/// Widgetbook: https://zeta-ds.web.app/flutter/widgetbook/index.html#/?path=components/breadcrumbs -class ZetaBreadCrumbs extends ZetaStatefulWidget { - ///Constructor for [ZetaBreadCrumbs] - const ZetaBreadCrumbs({ - super.key, - super.rounded, - required this.children, - this.activeIcon, - this.moreSemanticLabel, - }); - - /// Breadcrumb children - final List children; - - /// Active icon for breadcrumb - final IconData? activeIcon; - - /// Label passed to wrapping [Semantics] widget. - /// {@macro zeta-widget-semantic-label} - final String? moreSemanticLabel; - - @override - State createState() => _ZetaBreadCrumbsState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('children', children)) - ..add(DiagnosticsProperty('rounded', rounded)) - ..add(DiagnosticsProperty('activeIcon', activeIcon)) - ..add(StringProperty('moreSemanticLabel', moreSemanticLabel)); - } -} - -class _ZetaBreadCrumbsState extends State { - late int _selectedIndex; - late List _children; - - @override - void initState() { - super.initState(); - _selectedIndex = widget.children.length - 1; - _children = [...widget.children]; - } - - @override - void didUpdateWidget(ZetaBreadCrumbs oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.children.length != _children.length) { - _selectedIndex = widget.children.length - 1; - _children = [...widget.children]; - } - } - - @override - Widget build(BuildContext context) { - final rounded = context.rounded; - return ZetaRoundedScope( - rounded: rounded, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: renderedChildren(widget.children) - .divide( - Row( - children: [ - SizedBox(width: Zeta.of(context).spacing.small), - ZetaIcon(ZetaIcons.chevron_right, size: Zeta.of(context).spacing.xl), - SizedBox(width: Zeta.of(context).spacing.small), - ], - ), - ) - .toList(), - ), - ), - ); - } - - ///Creates breadcrumb widget - ZetaBreadCrumb createBreadCrumb(ZetaBreadCrumb input, int index) { - return ZetaBreadCrumb( - label: input.label, - isSelected: _selectedIndex == index, - onPressed: () { - setState(() { - _selectedIndex = index; - }); - input.onPressed.call(); - }, - activeIcon: widget.activeIcon, - ); - } - - List renderedChildren(List children) { - final List returnList = []; - if (children.length > 3) { - returnList.add(createBreadCrumb(children.first, 0)); - - final List truncatedChildren = []; - - for (final (index, element) in children.sublist(1, children.length - 1).indexed) { - truncatedChildren.add(createBreadCrumb(element, index + 1)); - } - returnList - ..add(_BreadCrumbsTruncated(semanticLabel: widget.moreSemanticLabel, children: truncatedChildren)) - ..add(createBreadCrumb(children.last, children.length - 1)); - } else { - for (final (index, element) in children.indexed) { - returnList.add(createBreadCrumb(element, index)); - } - } - return returnList; - } -} - -// TODO(UX-1131): Rename to breadcrumbitem - -/// Class for untruncated [ZetaBreadCrumb]. -/// -/// Should be a child of [ZetaBreadCrumbs]. -class ZetaBreadCrumb extends StatefulWidget { - ///Constructor for [ZetaBreadCrumb] - const ZetaBreadCrumb({ - super.key, - required this.label, - this.icon, - this.isSelected = false, - required this.onPressed, - this.activeIcon, - this.semanticLabel, - }); - - /// [ZetaBreadCrumb] label. - final String label; - - /// Selected icon. - final IconData? icon; - - /// Is [ZetaBreadCrumb] selected. - final bool isSelected; - - /// Handles press for [ZetaBreadCrumb] - final VoidCallback onPressed; - - /// Active icon for [ZetaBreadCrumb] - final IconData? activeIcon; - - /// Label passed to wrapping [Semantics] widget. - /// - /// {@macro zeta-widget-semantic-label} - /// - /// If null, [label] is used. - final String? semanticLabel; - - @override - State createState() => _ZetaBreadCrumbState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ObjectFlagProperty.has('onPressed', onPressed)) - ..add(StringProperty('label', label)) - ..add(DiagnosticsProperty('icon', icon)) - ..add(DiagnosticsProperty('isSelected', isSelected)) - ..add(DiagnosticsProperty('activeIcon', activeIcon)) - ..add(StringProperty('semanticLabel', semanticLabel)); - } -} - -class _ZetaBreadCrumbState extends State { - final controller = WidgetStatesController(); - - @override - void initState() { - super.initState(); - controller.addListener(() { - if (context.mounted && mounted) { - setState(() {}); - } - }); - } - - @override - Widget build(BuildContext context) { - final colors = Zeta.of(context).colors; - return Semantics( - label: widget.semanticLabel ?? widget.label, - selected: widget.isSelected, - child: InkWell( - statesController: controller, - onTap: widget.onPressed, - enableFeedback: false, - splashColor: Colors.transparent, - overlayColor: WidgetStateProperty.resolveWith((states) { - return Colors.transparent; - }), - child: Row( - children: [ - if (widget.isSelected) - Icon( - widget.activeIcon ?? ZetaIcons.star_round, - color: getColor(controller.value, colors), - ), - SizedBox( - width: Zeta.of(context).spacing.small, - ), - Text( - widget.label, - style: ZetaTextStyles.bodySmall.apply(color: getColor(controller.value, colors)), - ), - ], - ), - ), - ); - } - - /// Get color of breadcrumb based on state. - Color getColor(Set states, ZetaColors colors) { - if (states.contains(WidgetState.hovered)) { - return colors.blue; - } - if (widget.isSelected) return colors.black; - return colors.textSubtle; - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add( - DiagnosticsProperty( - 'controller', - controller, - ), - ); - } -} - -/// Class for [_BreadCrumbsTruncated] -@Deprecated('This functionality is not needed anymore. Use [ZetaBreadCrumb] instead. ' 'Deprecated since 0.14.1') -typedef BreadCrumbsTruncated = _BreadCrumbsTruncated; - -class _BreadCrumbsTruncated extends StatefulWidget { - ///Constructor for [_BreadCrumbsTruncated] - const _BreadCrumbsTruncated({ - required this.children, - this.semanticLabel, - }); - - ///Breadcrumb children - final List children; - - /// Label passed to wrapping [Semantics] widget. - /// - /// {@macro zeta-widget-semantic-label} - final String? semanticLabel; - - @override - State<_BreadCrumbsTruncated> createState() => _BreadCrumbsTruncatedState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(StringProperty('semanticLabel', semanticLabel)); - } -} - -class _BreadCrumbsTruncatedState extends State<_BreadCrumbsTruncated> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - final colors = Zeta.of(context).colors; - final rounded = context.rounded; - - return _expanded - ? expandedBreadcrumb() - : Semantics( - label: widget.semanticLabel, - button: true, - child: FilledButton( - onPressed: () { - setState(() { - _expanded = true; - }); - }, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered)) { - return colors.surfaceHover; - } - if (states.contains(WidgetState.pressed)) { - return colors.primary.shade10; - } - if (states.contains(WidgetState.disabled)) { - return colors.surfaceDisabled; - } - return colors.warm.shade10; - }), - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.textDisabled; - } - return colors.textDefault; - }), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: (rounded ? Zeta.of(context).radius.minimal : Zeta.of(context).radius.none), - ), - ), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return BorderSide( - width: ZetaBorders.medium, - color: colors.primary.shade100, - ); - } - if (states.isEmpty) { - return BorderSide(color: colors.borderDefault, width: 0.5); - } - return null; - }), - padding: WidgetStateProperty.all(EdgeInsets.zero), - minimumSize: WidgetStateProperty.all(Size.zero), - elevation: WidgetStatePropertyAll(Zeta.of(context).spacing.none), - ), - child: Icon( - rounded ? ZetaIcons.more_horizontal_round : ZetaIcons.more_horizontal_sharp, - size: Zeta.of(context).spacing.large, - ).paddingHorizontal(Zeta.of(context).spacing.small).paddingVertical(Zeta.of(context).spacing.minimum), - ), - ); - } - - Widget expandedBreadcrumb() { - return Row( - children: widget.children - .divide( - Row( - children: [ - SizedBox(width: Zeta.of(context).spacing.small), - ZetaIcon(ZetaIcons.chevron_right, size: Zeta.of(context).spacing.xl), - SizedBox(width: Zeta.of(context).spacing.small), - ], - ), - ) - .toList(), - ); - } -} diff --git a/lib/src/components/components.dart b/lib/src/components/components.dart index 63bae3a4..38a024cb 100644 --- a/lib/src/components/components.dart +++ b/lib/src/components/components.dart @@ -9,7 +9,7 @@ export 'badges/tag.dart'; export 'banner/banner.dart'; export 'bottom sheets/bottom_sheet.dart'; export 'bottom sheets/menu_items.dart'; -export 'breadcrumbs/breadcrumbs.dart'; +export 'breadcrumb/breadcrumb.dart'; export 'button_group/button_group.dart'; export 'buttons/button.dart'; export 'buttons/button_style.dart'; diff --git a/test/src/components/breadcrumb/breadcrumb_test.dart b/test/src/components/breadcrumb/breadcrumb_test.dart new file mode 100644 index 00000000..bff2fc5f --- /dev/null +++ b/test/src/components/breadcrumb/breadcrumb_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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 = 'breadcrumb'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + final List children = [ + ZetaBreadcrumbItem( + label: 'Breadcrumb', + onPressed: () {}, + icon: ZetaIcons.star, + ), + ZetaBreadcrumbItem(label: 'Item 1', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 2', onPressed: () {}), + ZetaBreadcrumbItem( + label: 'Item 3', + onPressed: () {}, + icon: ZetaIcons.star, + ), + ZetaBreadcrumbItem(label: 'Item 4', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 5', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 6', onPressed: () {}), + ]; + + group('Accessibility Tests', () { + testWidgets('ZetaBreadcrumb meets accessibility standards', (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + TestApp( + home: ZetaBreadcrumb( + children: children, + ), + ), + ); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + }); + + group('Content Tests', () { + final debugFillProperties = { + 'children': children.toString().replaceAll('[', '').replaceAll(']', ''), + }; + debugFillPropertiesTest( + ZetaBreadcrumb(children: children), + debugFillProperties, + ); + + // has the correct number of children + testWidgets('ZetaBreadcrumb has the correct number of children', (tester) async { + const maxItemsShown = 2; + + await tester.pumpWidget( + TestApp( + home: ZetaBreadcrumb( + children: children, + ), + ), + ); + expect(find.byType(ZetaBreadcrumbItem), findsNWidgets(maxItemsShown)); + + // ignore: deprecated_member_use_from_same_package + await tester.tap(find.byType(TruncatedItem)); + await tester.pumpAndSettle(); + + expect(find.byType(ZetaBreadcrumbItem), findsNWidgets(children.length)); + }); + + // children have the correct labels + testWidgets('ZetaBreadcrumb children have the correct labels', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaBreadcrumb( + children: children, + ), + ), + ); + + expect(find.text('Breadcrumb'), findsOneWidget); + expect(find.text('Item ${children.length - 1}'), findsOneWidget); + + // ignore: deprecated_member_use_from_same_package + await tester.tap(find.byType(TruncatedItem)); + await tester.pumpAndSettle(); + + for (int i = 0; i < children.length; i++) { + expect(find.text(children[i].label), findsOneWidget); + } + }); + + // children have the correct icons (if any) + testWidgets(' ZetaBreadcrumb children have the correct icons', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaBreadcrumb( + children: children, + ), + ), + ); + + final iconFinder = find.byWidgetPredicate((widget) { + if (widget is ZetaIcon) { + return widget.icon == ZetaIcons.star; + } + return false; + }); + expect(iconFinder, findsOneWidget); + + // ignore: deprecated_member_use_from_same_package + await tester.tap(find.byType(TruncatedItem)); + await tester.pumpAndSettle(); + + final iconFinder1 = find.byWidgetPredicate((widget) { + if (widget is ZetaIcon) { + return widget.icon == ZetaIcons.star; + } + return false; + }); + expect(iconFinder1, findsNWidgets(2)); + }); + }); + + group('Dimensions Tests', () { + // each item is equally spaced + testWidgets('ZetaBreadcrumb items are equally spaced', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaBreadcrumb( + maxItemsShown: 7, + children: [ + ZetaBreadcrumbItem(label: 'Item 0', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 1', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 2', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 3', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 4', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 5', onPressed: () {}), + ZetaBreadcrumbItem(label: 'Item 6', onPressed: () {}), + ], + ), + ), + ); + + final itemFinder = find.byType(ZetaBreadcrumbItem); + + final List itemPositions = []; + for (int i = 0; i < children.length; i++) { + expect(itemFinder.at(i), findsOneWidget); + itemPositions.add(tester.getTopLeft(itemFinder.at(i)).dx); + } + + final double distance = itemPositions[1] - itemPositions[0]; + for (int i = 1; i < itemPositions.length; i++) { + expect(itemPositions[i] - itemPositions[i - 1], distance); + } + }); + }); + + group('Styling Tests', () { + testWidgets('ZetaBreadcrumb label and icon colors are correct', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaBreadcrumb( + maxItemsShown: 7, + children: children, + ), + ), + ); + final context = getBuildContext(tester, ZetaBreadcrumb); + final colors = Zeta.of(context).colors; + + final labelFinder = find.byType(Text); + final iconFinder = find.byType(ZetaIcon); + + for (int i = 0; i < children.length; i++) { + if (i == children.length - 1) { + expect( + (tester.firstWidget(labelFinder.at(i)) as Text).style?.color, + colors.black, + ); + } else { + expect( + (tester.firstWidget(labelFinder.at(i)) as Text).style?.color, + colors.textSubtle, + ); + } + } + + for (int i = 0; i < iconFinder.evaluate().length; i++) { + expect( + (tester.firstWidget(iconFinder.at(i)) as ZetaIcon).color, + colors.textSubtle, + ); + } + }); + + // label font styles are correct + // icon sizes are correct + + // hovered state label and icon colors are correct + // selected state label and icon colors are correct + }); + + group('Interaction Tests', () { + // clicking on an item calls the onPressed callback + // clicking on an item removes the children list after it + }); + + group('Golden Tests', () { + // goldenTest(goldenFile, widget, 'PNG_FILE_NAME'); + }); + + group('Performance Tests', () {}); +}