diff --git a/example/lib/pages/components/chip_example.dart b/example/lib/pages/components/chip_example.dart index 72a96407..899a8c38 100644 --- a/example/lib/pages/components/chip_example.dart +++ b/example/lib/pages/components/chip_example.dart @@ -63,6 +63,21 @@ class _ChipExampleState extends State { ), ), ]); + + final Widget statusChipExample = Column(children: [ + Text( + 'Status Chip', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + ZetaStatusChip( + label: 'Label', + data: 'Status chip', + draggable: true, + ), + ]); + final colors = Zeta.of(context).colors; return ExampleScaffold( name: ChipExample.name, @@ -74,6 +89,7 @@ class _ChipExampleState extends State { inputChipExample, assistChipExample, filterChipExample, + statusChipExample, const SizedBox(height: 100), DragTarget( onAcceptWithDetails: (details) => setState(() => chipType = details.data), diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 6a5202c6..7eeba843 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -163,6 +163,7 @@ class _HotReloadState extends State { WidgetbookUseCase(name: 'Filter Chip', builder: (context) => filterChipUseCase(context)), WidgetbookUseCase(name: 'Input Chip', builder: (context) => inputChipUseCase(context)), WidgetbookUseCase(name: 'Assist Chip', builder: (context) => assistChipUseCase(context)), + WidgetbookUseCase(name: 'Status Chip', builder: (context) => statusChipUseCase(context)), ], ), WidgetbookComponent( diff --git a/example/widgetbook/pages/components/chip_widgetbook.dart b/example/widgetbook/pages/components/chip_widgetbook.dart index d8e7848a..057d6d6c 100644 --- a/example/widgetbook/pages/components/chip_widgetbook.dart +++ b/example/widgetbook/pages/components/chip_widgetbook.dart @@ -39,3 +39,14 @@ Widget assistChipUseCase(BuildContext context) { ), ); } + +Widget statusChipUseCase(BuildContext context) { + return WidgetbookScaffold( + builder: (context, _) => ZetaStatusChip( + label: context.knobs.string(label: 'Label', initialValue: 'Label'), + draggable: context.knobs.boolean(label: 'Draggable', initialValue: false), + rounded: context.knobs.boolean(label: 'Rounded', initialValue: true), + onDragCompleted: () => print('Drag completed'), + ), + ); +} diff --git a/lib/src/components/chips/chip.dart b/lib/src/components/chips/chip.dart index 545f0410..9f3a0fca 100644 --- a/lib/src/components/chips/chip.dart +++ b/lib/src/components/chips/chip.dart @@ -6,6 +6,7 @@ import '../../../zeta_flutter.dart'; export './assist_chip.dart'; export './filter_chip.dart'; export './input_chip.dart'; +export './status_chip.dart'; /// This covers the broad functionality of [ZetaAssistChip], [ZetaFilterChip] and [ZetaInputChip]. /// diff --git a/lib/src/components/chips/status_chip.dart b/lib/src/components/chips/status_chip.dart new file mode 100644 index 00000000..cdd205dd --- /dev/null +++ b/lib/src/components/chips/status_chip.dart @@ -0,0 +1,125 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +/// The [ZetaStatusChip] is a chip that displays a status/label. +/// It can be dragged around the screen and placed in new locations using [DragTarget]. +/// +/// {@category Components} +/// +/// Figma: https://www.figma.com/file/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=21265-2159 +/// +/// Widgetbook: https://zeta-ds.web.app/flutter/widgetbook/index.html#/?path=components/chips/input-chip +class ZetaStatusChip extends ZetaStatelessWidget { + /// Creates a [ZetaStatusChip]. + const ZetaStatusChip({ + super.key, + required this.label, + super.rounded = true, + this.draggable = false, + this.data, + this.onDragCompleted, + this.semanticLabel, + }); + + /// The label on the [ZetaStatusChip] + final String label; + + /// Whether the chip can be dragged. + final bool draggable; + + /// Draggable data. + final dynamic data; + + /// Called when the draggable is dropped and accepted by a [DragTarget]. + /// + /// See also: + /// * [DragTarget] + /// * [Draggable] + final VoidCallback? onDragCompleted; + + /// A semantic label for the chip. + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return ZetaRoundedScope( + rounded: context.rounded, + child: MouseRegion( + cursor: draggable ? SystemMouseCursors.click : SystemMouseCursors.basic, + child: Semantics( + label: semanticLabel ?? label, + child: SelectionContainer.disabled( + child: draggable + ? Draggable( + feedback: Material( + color: Colors.transparent, + child: _Child(context: context, label: label), + ), + childWhenDragging: const Nothing(), + data: data, + onDragCompleted: onDragCompleted, + child: _Child(context: context, label: label), + ) + : _Child(context: context, label: label), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('label', label)) + ..add(DiagnosticsProperty('draggable', draggable)) + ..add(DiagnosticsProperty('data', data)) + ..add(ObjectFlagProperty.has('onDragCompleted', onDragCompleted)) + ..add(StringProperty('semanticLabel', semanticLabel)); + } +} + +/// The child widget of the [ZetaStatusChip]. +class _Child extends StatelessWidget { + const _Child({required this.context, required this.label}); + + final BuildContext context; + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Zeta.of(context).colors.surfaceWarm, + borderRadius: context.rounded + ? BorderRadius.all( + Radius.circular( + Zeta.of(context).spacing.small, + ), + ) + : null, + ), + padding: EdgeInsets.symmetric( + horizontal: Zeta.of(context).spacing.small, + vertical: Zeta.of(context).spacing.minimum, + ), + child: Text( + label, + style: ZetaTextStyles.bodyXSmall.copyWith( + color: Zeta.of(context).colors.textDefault, + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('context', context)) + ..add(StringProperty('label', label)); + } +} diff --git a/test/src/components/chips/golden/status_chip_default.png b/test/src/components/chips/golden/status_chip_default.png new file mode 100644 index 00000000..e0bad968 Binary files /dev/null and b/test/src/components/chips/golden/status_chip_default.png differ diff --git a/test/src/components/chips/golden/status_chip_long.png b/test/src/components/chips/golden/status_chip_long.png new file mode 100644 index 00000000..03f56625 Binary files /dev/null and b/test/src/components/chips/golden/status_chip_long.png differ diff --git a/test/src/components/chips/golden/status_chip_sharp.png b/test/src/components/chips/golden/status_chip_sharp.png new file mode 100644 index 00000000..e0bad968 Binary files /dev/null and b/test/src/components/chips/golden/status_chip_sharp.png differ diff --git a/test/src/components/chips/status_chip_test.dart b/test/src/components/chips/status_chip_test.dart new file mode 100644 index 00000000..5d993228 --- /dev/null +++ b/test/src/components/chips/status_chip_test.dart @@ -0,0 +1,226 @@ +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 = 'chips'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + group('Accessibility Tests', () { + testWidgets('label meets accessibility requirements', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label', + ), + ), + ); + 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 = { + 'label': '"Label"', + 'draggable': 'false', + 'data': 'null', + 'onDragCompleted': 'null', + 'semanticLabel': 'null', + }; + debugFillPropertiesTest( + const ZetaStatusChip( + label: 'Label', + ), + debugFillProperties, + ); + + testWidgets('renders label correctly', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label', + ), + ), + ); + + expect(find.text('Label'), findsOneWidget); + }); + + testWidgets('renders label correctly when label is long', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label that is very very long', + ), + ), + ); + + final Finder textFinder = find.text('Label that is very very long'); + + expect(textFinder, findsOneWidget); + }); + }); + + group('Dimensions Tests', () { + testWidgets('horizontal padding is 8 and vertical padding is 4', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label', + ), + ), + ); + final Finder finder = find.byType(Container); + expect(finder, findsOneWidget); + final Container widget = tester.widget(finder); + expect( + widget.padding, + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + ); + }); + }); + + group('Styling Tests', () { + testWidgets('background color is ZetaColors.surfaceWarm and border radius is 8', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label', + ), + ), + ); + final Finder finder = find.byType(Container); + expect(finder, findsOneWidget); + final Container widget = tester.widget(finder); + expect( + widget.decoration, + BoxDecoration( + color: ZetaColors.light().surfaceWarm, + borderRadius: BorderRadius.circular(8), + ), + ); + }); + + testWidgets('border radius is null when rounded is false', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label', + rounded: false, + ), + ), + ); + final Finder finder = find.byType(Container); + expect(finder, findsOneWidget); + final Container widget = tester.widget(finder); + expect( + widget.decoration, + BoxDecoration( + color: ZetaColors.light().surfaceWarm, + ), + ); + }); + + testWidgets('text style is ZetaTextStyles.bodyXSmall and text color is textDefault', (WidgetTester tester) async { + await tester.pumpWidget( + const TestApp( + home: ZetaStatusChip( + label: 'Label', + ), + ), + ); + final Finder finder = find.byType(Text); + expect(finder, findsOneWidget); + final Text widget = tester.widget(finder); + expect(widget.style, ZetaTextStyles.bodyXSmall.copyWith(color: ZetaColors.light().textDefault)); + }); + }); + + group('Interaction Tests', () { + testWidgets('chip calls onDragCompleted and parses data to DragTarget when drag is completed', + (WidgetTester tester) async { + bool dragCompleted = false; + String? dragData; + + await tester.pumpWidget( + TestApp( + home: Column( + children: [ + ZetaStatusChip( + label: 'Label', + draggable: true, + data: 'data', + onDragCompleted: () { + dragCompleted = true; + }, + ), + DragTarget( + onAcceptWithDetails: (details) => dragData = details.data, + builder: (BuildContext context, List candidateData, List rejectedData) { + return const SizedBox( + width: 100, + height: 100, + ); + }, + ), + ], + ), + ), + ); + + final Finder finder = find.byType(Draggable); + expect(finder, findsOneWidget); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(finder)); + await gesture.moveTo(tester.getCenter(find.byType(DragTarget))); + await gesture.up(); + + expect(dragCompleted, isTrue); + expect(dragData, 'data'); + }); + }); + + group('Golden Tests', () { + goldenTest( + goldenFile, + const ZetaStatusChip( + label: 'Label', + ), + 'status_chip_default', + ); + goldenTest( + goldenFile, + const ZetaStatusChip( + label: 'Label', + rounded: false, + ), + 'status_chip_sharp', + ); + goldenTest( + goldenFile, + const ZetaStatusChip( + label: 'Label that is very very long', + ), + 'status_chip_long', + ); + }); + + group('Performance Tests', () {}); +}