diff --git a/docs.json b/docs.json index c8c75e6a..58febc4e 100644 --- a/docs.json +++ b/docs.json @@ -117,6 +117,10 @@ "title": "Menu", "href": "/components/menu" }, + { + "title": "Popover", + "href": "/components/popover" + }, { "title": "Progress", "href": "/components/progress" diff --git a/docs/components/popover.mdx b/docs/components/popover.mdx new file mode 100644 index 00000000..885d4f0a --- /dev/null +++ b/docs/components/popover.mdx @@ -0,0 +1,158 @@ +--- +title: Popover +description: An anchored, dismissible overlay for supplementary interactive content. +--- + +`RemixPopover` displays rich content next to a trigger. It uses `NakedPopover` +for positioning, focus restoration, keyboard activation, outside-tap dismissal, +and programmatic control while Mix styles the overlay surface. + +Use a popover for contextual details, lightweight forms, previews, and actions. +Use `RemixTooltip` for short, non-interactive hints and `RemixDialog` when the +user must address modal content before continuing. + +## Basic usage + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class AccountPopover extends StatelessWidget { + const AccountPopover({super.key}); + + @override + Widget build(BuildContext context) { + return FortalPopover( + semanticLabel: 'Show account details', + positioning: const OverlayPositionConfig( + targetAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + ), + popoverChild: const SizedBox( + width: 240, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Signed in as'), + SizedBox(height: 8), + Text('person@example.com'), + ], + ), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Text('Account'), + ), + ); + } +} +``` + +The `child` is the trigger surface. `RemixPopover` supplies its tap, keyboard, +focus, and button semantics, so the trigger normally should be visual content +rather than another independently interactive button. + +## Programmatic control + +Provide a Flutter `MenuController` and disable `openOnTap` when another event +owns the open state. + +```dart +final controller = MenuController(); + +RemixPopover( + controller: controller, + openOnTap: false, + popoverChild: const Text('Controlled content'), + child: TextButton( + onPressed: () => controller.open(), + child: const Text('Open controlled popover'), + ), +); + +controller.close(); +``` + +With `openOnTap: false`, the child owns activation and its accessibility +semantics. Give an interactive child its own accessible name and action, or +provide an equivalent accessible control elsewhere. The popover preserves +those semantics and adds its expanded/collapsed state. + +`onOpenRequested` and `onCloseRequested` can delay or animate a transition. +The request callbacks must invoke their provided `showOverlay` or `hideOverlay` +callback to complete the state change. + +## Positioning + +`OverlayPositionConfig` aligns one point on the trigger with one point on the +overlay and can apply an additional offset. + +```dart +const OverlayPositionConfig( + targetAnchor: Alignment.topRight, + followerAnchor: Alignment.bottomRight, + offset: Offset(0, -8), +) +``` + +The overlay is clamped to the available screen bounds. + +## Styling + +`RemixPopoverStyler` styles the overlay container. The trigger keeps its own +visual styling. + +```dart +RemixPopover( + style: RemixPopoverStyler() + .paddingAll(16) + .constraints(BoxConstraintsMix(maxWidth: 320)) + .backgroundColor(Colors.white) + .borderRadiusAll(12), + popoverChild: const Text('Custom popover'), + child: const Text('Open'), +) +``` + +The `FortalPopover` preset adds Fortal spacing, border, radius, surface color, +shadow, and a maximum width. Content remains fully composable. + +## Keyboard and accessibility + +- Tap the trigger or press Space or Enter while it is focused to toggle the popover. +- Press Escape to close it and return focus to the trigger. +- Clicking outside closes the overlay. `consumeOutsideTaps` controls whether that tap reaches the widget behind it. +- Use `semanticLabel` when the built-in trigger's visual content does not provide a clear accessible name. With `openOnTap: false`, label the interactive child instead. +- Put interactive content in a logical focus order. Wrap complex content in `FocusTraversalGroup` when it needs a custom traversal policy. + +## Constructor + +```dart +const RemixPopover({ + Key? key, + required Widget popoverChild, + required Widget child, + OverlayPositionConfig positioning = const OverlayPositionConfig(), + bool consumeOutsideTaps = true, + bool useRootOverlay = false, + bool openOnTap = true, + FocusNode? triggerFocusNode, + VoidCallback? onOpen, + VoidCallback? onClose, + RawMenuAnchorOpenRequestedCallback? onOpenRequested, + RawMenuAnchorCloseRequestedCallback? onCloseRequested, + MenuController? controller, + String? semanticLabel, + bool excludeSemantics = false, + RemixPopoverStyler style = const RemixPopoverStyler.create(), + RemixPopoverSpec? styleSpec, +}) +``` + +## Style methods + +The styler includes the standard Remix container methods, including `container`, +`padding`, `margin`, `alignment`, `color`, `backgroundColor`, `border`, +`borderRadius`, `shadow`, `constraints`, `decoration`, `foregroundDecoration`, +`transform`, `animate`, `variants`, `wrap`, and `modifier`. diff --git a/packages/demo/lib/components/popover.dart b/packages/demo/lib/components/popover.dart new file mode 100644 index 00000000..c7ccd56b --- /dev/null +++ b/packages/demo/lib/components/popover.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +final _key = GlobalKey(); + +@widgetbook.UseCase(name: 'Popover Component', type: RemixPopover) +Widget buildPopoverUseCase(BuildContext context) { + final consumeOutsideTaps = context.knobs.boolean( + label: 'Consume outside taps', + initialValue: true, + ); + + return KeyedSubtree( + key: _key, + child: Scaffold( + body: Center( + child: FortalPopover( + consumeOutsideTaps: consumeOutsideTaps, + semanticLabel: 'Show collaboration details', + positioning: const OverlayPositionConfig( + targetAnchor: .bottomCenter, + followerAnchor: .topCenter, + ), + popoverChild: SizedBox( + width: 280, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 12, + children: [ + StyledText( + 'Invite teammates', + style: TextStyler() + .fontSize(16) + .fontWeight(.w600) + .color(FortalTokens.gray12()), + ), + StyledText( + 'Share this project with teammates and choose what they can access.', + style: TextStyler().fontSize(14).color(FortalTokens.gray11()), + ), + FortalButton.soft( + label: 'Copy invite link', + leadingIcon: Icons.link, + onPressed: () {}, + ), + ], + ), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Icon(Icons.group_add_outlined, size: 18), + Text('Invite'), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/packages/demo/lib/main.directories.g.dart b/packages/demo/lib/main.directories.g.dart index c0dae544..84520931 100644 --- a/packages/demo/lib/main.directories.g.dart +++ b/packages/demo/lib/main.directories.g.dart @@ -22,6 +22,7 @@ import 'package:demo/components/divider.dart' as _demo_components_divider; import 'package:demo/components/icon_button.dart' as _demo_components_icon_button; import 'package:demo/components/menu.dart' as _demo_components_menu; +import 'package:demo/components/popover.dart' as _demo_components_popover; import 'package:demo/components/progress.dart' as _demo_components_progress; import 'package:demo/components/radio.dart' as _demo_components_radio; import 'package:demo/components/select.dart' as _demo_components_select; @@ -137,6 +138,15 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'RemixPopover', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Popover Component', + builder: _demo_components_popover.buildPopoverUseCase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'RemixProgress', useCases: [ diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart index f6320996..55f2c6bb 100644 --- a/packages/remix/lib/remix.dart +++ b/packages/remix/lib/remix.dart @@ -13,6 +13,7 @@ export 'src/components/card/card.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/divider/divider.dart'; export 'src/components/menu/menu.dart'; +export 'src/components/popover/popover.dart'; export 'src/components/progress/progress.dart'; export 'src/components/radio/radio.dart'; export 'src/components/select/select.dart'; diff --git a/packages/remix/lib/src/components/popover/fortal_popover_styles.dart b/packages/remix/lib/src/components/popover/fortal_popover_styles.dart new file mode 100644 index 00000000..5754df39 --- /dev/null +++ b/packages/remix/lib/src/components/popover/fortal_popover_styles.dart @@ -0,0 +1,23 @@ +part of 'popover.dart'; + +/// Fortal-themed preset for [RemixPopover]. +@MixWidget(name: 'FortalPopover') +RemixPopoverStyler fortalPopoverStyler() { + return RemixPopoverStyler() + .paddingAll(FortalTokens.space4()) + .marginTop(FortalTokens.space2()) + .constraints(BoxConstraintsMix(maxWidth: 360)) + .borderAll( + color: FortalTokens.gray6(), + width: FortalTokens.borderWidth1(), + ) + .borderRadiusAll(FortalTokens.radius3()) + .backgroundColor(FortalTokens.gray1()) + .shadow( + BoxShadowMix() + .color(FortalTokens.blackA3()) + .offset(x: 0, y: 4) + .blurRadius(12) + .spreadRadius(0), + ); +} diff --git a/packages/remix/lib/src/components/popover/popover.dart b/packages/remix/lib/src/components/popover/popover.dart new file mode 100644 index 00000000..e861be47 --- /dev/null +++ b/packages/remix/lib/src/components/popover/popover.dart @@ -0,0 +1,16 @@ +library remix_popover; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:naked_ui/naked_ui.dart'; + +import '../../fortal/fortal.dart'; +import '../../utilities/remix_style.dart'; + +part 'fortal_popover_styles.dart'; +part 'popover_spec.dart'; +part 'popover_style.dart'; +part 'popover_widget.dart'; +part 'popover.g.dart'; diff --git a/packages/remix/lib/src/components/popover/popover.g.dart b/packages/remix/lib/src/components/popover/popover.g.dart new file mode 100644 index 00000000..c38e5219 --- /dev/null +++ b/packages/remix/lib/src/components/popover/popover.g.dart @@ -0,0 +1,215 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'popover.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$RemixPopoverSpec implements Spec, Diagnosticable { + StyleSpec get container; + + @override + Type get type => RemixPopoverSpec; + + @override + RemixPopoverSpec copyWith({StyleSpec? container}) { + return RemixPopoverSpec(container: container ?? this.container); + } + + @override + RemixPopoverSpec lerp(RemixPopoverSpec? other, double t) { + return RemixPopoverSpec(container: container.lerp(other?.container, t)); + } + + @override + List get props => [container]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is RemixPopoverSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties.add(DiagnosticsProperty('container', container)); + } +} + +@Deprecated( + 'Rename to `_\$RemixPopoverSpec` and migrate the class declaration to `class RemixPopoverSpec with _\$RemixPopoverSpec`. The `_\$RemixPopoverSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$RemixPopoverSpecMethods = _$RemixPopoverSpec; // ignore: unused_element + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixPopover]. +class FortalPopover extends StatelessWidget { + const FortalPopover({ + super.key, + required this.popoverChild, + required this.child, + this.positioning = const OverlayPositionConfig(), + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.openOnTap = true, + this.triggerFocusNode, + this.onOpen, + this.onClose, + this.onOpenRequested, + this.onCloseRequested, + this.controller, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final Widget popoverChild; + + final Widget child; + + final OverlayPositionConfig positioning; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool openOnTap; + + final FocusNode? triggerFocusNode; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final MenuController? controller; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return fortalPopoverStyler().call( + key: this.key, + popoverChild: this.popoverChild, + child: this.child, + positioning: this.positioning, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + openOnTap: this.openOnTap, + triggerFocusNode: this.triggerFocusNode, + onOpen: this.onOpen, + onClose: this.onClose, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + controller: this.controller, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} + +// ************************************************************************** +// StylerGenerator +// ************************************************************************** + +mixin _$RemixPopoverStylerMixin on Style, Diagnosticable { + Prop>? get $container; + + /// Sets the container. + RemixPopoverStyler container(BoxStyler value) { + return merge(RemixPopoverStyler(container: value)); + } + + /// Sets the animation configuration. + RemixPopoverStyler animate(AnimationConfig value) { + return merge(RemixPopoverStyler(animation: value)); + } + + /// Sets the style variants. + RemixPopoverStyler variants(List> value) { + return merge(RemixPopoverStyler(variants: value)); + } + + /// Wraps with a widget modifier. + RemixPopoverStyler wrap(WidgetModifierConfig value) { + return merge(RemixPopoverStyler(modifier: value)); + } + + /// Sets the widget modifier. + RemixPopoverStyler modifier(WidgetModifierConfig value) { + return merge(RemixPopoverStyler(modifier: value)); + } + + /// Merges with another [RemixPopoverStyler]. + @override + RemixPopoverStyler merge(RemixPopoverStyler? other) { + return RemixPopoverStyler.create( + container: MixOps.merge($container, other?.$container), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = RemixPopoverSpec( + container: MixOps.resolve(context, $container), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('container', $container)); + } + + @override + List get props => [$container, $animation, $modifier, $variants]; +} diff --git a/packages/remix/lib/src/components/popover/popover_spec.dart b/packages/remix/lib/src/components/popover/popover_spec.dart new file mode 100644 index 00000000..a70598b9 --- /dev/null +++ b/packages/remix/lib/src/components/popover/popover_spec.dart @@ -0,0 +1,11 @@ +part of 'popover.dart'; + +/// Resolved visual properties for a [RemixPopover] overlay. +@MixableSpec() +class RemixPopoverSpec with _$RemixPopoverSpec { + @override + final StyleSpec container; + + const RemixPopoverSpec({StyleSpec? container}) + : container = container ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix/lib/src/components/popover/popover_style.dart b/packages/remix/lib/src/components/popover/popover_style.dart new file mode 100644 index 00000000..2aba4a8f --- /dev/null +++ b/packages/remix/lib/src/components/popover/popover_style.dart @@ -0,0 +1,149 @@ +part of 'popover.dart'; + +/// Style configuration for a [RemixPopover] overlay container. +@MixableStyler() +class RemixPopoverStyler + extends RemixContainerStyler + with Diagnosticable, _$RemixPopoverStylerMixin { + @MixableField(setterType: BoxStyler) + final Prop>? $container; + + const RemixPopoverStyler.create({ + Prop>? container, + super.variants, + super.animation, + super.modifier, + }) : $container = container; + + RemixPopoverStyler({ + BoxStyler? container, + AnimationConfig? animation, + List>? variants, + WidgetModifierConfig? modifier, + }) : this.create( + container: Prop.maybeMix(container), + variants: variants, + animation: animation, + modifier: modifier, + ); + + /// Creates a style with the given padding. + factory RemixPopoverStyler.padding(EdgeInsetsGeometryMix value) => + RemixPopoverStyler().padding(value); + + /// Creates a style with the given margin. + factory RemixPopoverStyler.margin(EdgeInsetsGeometryMix value) => + RemixPopoverStyler().margin(value); + + /// Creates a style with the given border radius. + factory RemixPopoverStyler.borderRadius(BorderRadiusGeometryMix value) => + RemixPopoverStyler().borderRadius(value); + + /// Creates a style with the given alignment. + factory RemixPopoverStyler.alignment(Alignment value) => + RemixPopoverStyler().alignment(value); + + /// Creates a style with the given decoration. + factory RemixPopoverStyler.decoration(DecorationMix value) => + RemixPopoverStyler().decoration(value); + + /// Creates a style with the given background color. + factory RemixPopoverStyler.backgroundColor(Color value) => + RemixPopoverStyler().backgroundColor(value); + + /// Creates a style with the given constraints. + factory RemixPopoverStyler.constraints(BoxConstraintsMix value) => + RemixPopoverStyler().constraints(value); + + /// Sets the popover background color. + RemixPopoverStyler backgroundColor(Color value) => color(value); + + /// Creates a [RemixPopover] with this style applied. + RemixPopover call({ + Key? key, + required Widget popoverChild, + required Widget child, + OverlayPositionConfig positioning = const OverlayPositionConfig(), + bool consumeOutsideTaps = true, + bool useRootOverlay = false, + bool openOnTap = true, + FocusNode? triggerFocusNode, + VoidCallback? onOpen, + VoidCallback? onClose, + RawMenuAnchorOpenRequestedCallback? onOpenRequested, + RawMenuAnchorCloseRequestedCallback? onCloseRequested, + MenuController? controller, + String? semanticLabel, + bool excludeSemantics = false, + }) { + return RemixPopover( + key: key, + popoverChild: popoverChild, + positioning: positioning, + consumeOutsideTaps: consumeOutsideTaps, + useRootOverlay: useRootOverlay, + openOnTap: openOnTap, + triggerFocusNode: triggerFocusNode, + onOpen: onOpen, + onClose: onClose, + onOpenRequested: onOpenRequested, + onCloseRequested: onCloseRequested, + controller: controller, + semanticLabel: semanticLabel, + excludeSemantics: excludeSemantics, + style: this, + child: child, + ); + } + + /// Sets the overlay container alignment. + @override + RemixPopoverStyler alignment(Alignment value) { + return merge(RemixPopoverStyler(container: BoxStyler(alignment: value))); + } + + /// Sets padding inside the overlay container. + @override + RemixPopoverStyler padding(EdgeInsetsGeometryMix value) { + return merge(RemixPopoverStyler(container: BoxStyler(padding: value))); + } + + /// Sets margin around the overlay container. + @override + RemixPopoverStyler margin(EdgeInsetsGeometryMix value) { + return merge(RemixPopoverStyler(container: BoxStyler(margin: value))); + } + + /// Sets the overlay container decoration. + @override + RemixPopoverStyler decoration(DecorationMix value) { + return merge(RemixPopoverStyler(container: BoxStyler(decoration: value))); + } + + /// Sets constraints on the overlay container. + @override + RemixPopoverStyler constraints(BoxConstraintsMix value) { + return merge(RemixPopoverStyler(container: BoxStyler(constraints: value))); + } + + /// Sets a foreground decoration on the overlay container. + @override + RemixPopoverStyler foregroundDecoration(DecorationMix value) { + return merge( + RemixPopoverStyler(container: BoxStyler(foregroundDecoration: value)), + ); + } + + /// Applies a transform to the overlay container. + @override + RemixPopoverStyler transform( + Matrix4 value, { + AlignmentGeometry alignment = Alignment.center, + }) { + return merge( + RemixPopoverStyler( + container: BoxStyler(transform: value, transformAlignment: alignment), + ), + ); + } +} diff --git a/packages/remix/lib/src/components/popover/popover_widget.dart b/packages/remix/lib/src/components/popover/popover_widget.dart new file mode 100644 index 00000000..2fdb8592 --- /dev/null +++ b/packages/remix/lib/src/components/popover/popover_widget.dart @@ -0,0 +1,117 @@ +part of 'popover.dart'; + +/// A styled, anchored overlay for supplementary interactive content. +/// +/// The popover opens when [child] is tapped or activated from the keyboard. +/// [popoverChild] is rendered in the overlay using [style]. +class RemixPopover extends StatelessWidget { + const RemixPopover({ + super.key, + required this.popoverChild, + required this.child, + this.positioning = const OverlayPositionConfig(), + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.openOnTap = true, + this.triggerFocusNode, + this.onOpen, + this.onClose, + this.onOpenRequested, + this.onCloseRequested, + this.controller, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const RemixPopoverStyler.create(), + this.styleSpec, + }); + + /// Content displayed inside the popover overlay. + final Widget popoverChild; + + /// Trigger that opens and closes the popover. + final Widget child; + + /// Position of the overlay relative to [child]. + final OverlayPositionConfig positioning; + + /// Whether an outside tap is consumed after closing the popover. + final bool consumeOutsideTaps; + + /// Whether to render in the root overlay. + final bool useRootOverlay; + + /// Whether tapping or activating [child] toggles the popover. + final bool openOnTap; + + /// Optional focus node for the trigger. + final FocusNode? triggerFocusNode; + + /// Called after the popover opens. + final VoidCallback? onOpen; + + /// Called after the popover closes. + final VoidCallback? onClose; + + /// Intercepts a request to open the popover. + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + /// Intercepts a request to close the popover. + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + /// Optional controller for programmatic open and close operations. + final MenuController? controller; + + /// Accessibility label for the built-in trigger. + /// + /// When [openOnTap] is false, [child] owns its accessible label and actions. + final String? semanticLabel; + + /// Whether to hide the trigger subtree from accessibility. + final bool excludeSemantics; + + /// Style configuration for the overlay container. + final RemixPopoverStyler style; + + /// Optional resolved style that bypasses [style]. + final RemixPopoverSpec? styleSpec; + + static final styleFrom = RemixPopoverStyler.new; + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + builder: (context, spec) { + return NakedPopover( + popoverBuilder: (context, info) { + return Box(styleSpec: spec.container, child: popoverChild); + }, + positioning: positioning, + consumeOutsideTaps: consumeOutsideTaps, + useRootOverlay: useRootOverlay, + openOnTap: openOnTap, + triggerFocusNode: triggerFocusNode, + onOpen: onOpen, + onClose: onClose, + onOpenRequested: onOpenRequested, + onCloseRequested: onCloseRequested, + controller: controller, + excludeSemantics: excludeSemantics, + child: child, + builder: (context, state, trigger) { + final label = openOnTap ? semanticLabel : null; + final semantics = Semantics( + excludeSemantics: label != null, + expanded: state.isOpen, + label: label, + child: trigger!, + ); + + return openOnTap ? semantics : MergeSemantics(child: semantics); + }, + ); + }, + ); + } +} diff --git a/packages/remix/test/components/popover/popover_spec_test.dart b/packages/remix/test/components/popover/popover_spec_test.dart new file mode 100644 index 00000000..97ea699c --- /dev/null +++ b/packages/remix/test/components/popover/popover_spec_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('RemixPopoverSpec', () { + test('provides an empty container spec by default', () { + const spec = RemixPopoverSpec(); + + expect(spec.container, isA>()); + expect(spec.props, [spec.container]); + }); + + test('accepts and copies a custom container spec', () { + const container = StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints(minWidth: 240, maxWidth: 240), + ), + ); + const spec = RemixPopoverSpec(container: container); + + final copy = spec.copyWith(); + + expect(spec.container, same(container)); + expect(copy, equals(spec)); + expect(copy, isNot(same(spec))); + }); + + test('interpolates container specs', () { + const start = RemixPopoverSpec( + container: StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints(minWidth: 100, maxWidth: 100), + ), + ), + ); + const end = RemixPopoverSpec( + container: StyleSpec( + spec: BoxSpec( + constraints: BoxConstraints(minWidth: 200, maxWidth: 200), + ), + ), + ); + + expect(start.lerp(end, 0), equals(start)); + expect(start.lerp(end, 1), equals(end)); + }); + + test('supports diagnostics', () { + const spec = RemixPopoverSpec(); + + expect( + () => spec.debugFillProperties(DiagnosticPropertiesBuilder()), + returnsNormally, + ); + expect(spec.toString(), contains('container')); + }); + }); +} diff --git a/packages/remix/test/components/popover/popover_style_test.dart b/packages/remix/test/components/popover/popover_style_test.dart new file mode 100644 index 00000000..6743212f --- /dev/null +++ b/packages/remix/test/components/popover/popover_style_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_methods.dart'; + +void main() { + group('RemixPopoverStyler', () { + test('constructors retain container and universal style properties', () { + final animation = AnimationConfig.linear( + const Duration(milliseconds: 150), + ); + final modifier = WidgetModifierConfig(); + final style = RemixPopoverStyler( + container: BoxStyler(padding: EdgeInsetsGeometryMix.all(12)), + animation: animation, + variants: const [], + modifier: modifier, + ); + + expect(style.$container, isNotNull); + expect(style.$animation, animation); + expect(style.$variants, isEmpty); + expect(style.$modifier, modifier); + }); + + test('factory constructors create focused styles', () { + expect( + RemixPopoverStyler.backgroundColor(Colors.purple).$container, + isNotNull, + ); + expect( + RemixPopoverStyler.padding(EdgeInsetsGeometryMix.all(12)).$container, + isNotNull, + ); + expect( + RemixPopoverStyler.constraints( + BoxConstraintsMix(maxWidth: 320), + ).$container, + isNotNull, + ); + }); + + styleMethodTest( + 'container methods compose without mutating the original style', + initial: const RemixPopoverStyler.create(), + modify: (style) => style + .padding(EdgeInsetsGeometryMix.all(12)) + .margin(EdgeInsetsGeometryMix.all(8)) + .backgroundColor(Colors.purple) + .alignment(Alignment.center), + expect: (style) => expect(style.$container, isNotNull), + ); + + testWidgets('resolves to a RemixPopoverSpec', (tester) async { + const style = RemixPopoverStyler.create(); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + expect(style.resolve(context).spec, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + test('call creates a RemixPopover with this style', () { + final style = RemixPopoverStyler().backgroundColor(Colors.purple); + + final widget = style( + popoverChild: const Text('Content'), + child: const Text('Trigger'), + ); + + expect(widget.style, same(style)); + expect(widget.popoverChild, isA()); + expect(widget.child, isA()); + }); + }); +} diff --git a/packages/remix/test/components/popover/popover_widget_test.dart b/packages/remix/test/components/popover/popover_widget_test.dart new file mode 100644 index 00000000..64572587 --- /dev/null +++ b/packages/remix/test/components/popover/popover_widget_test.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + group('RemixPopover', () { + testWidgets('is closed by default and opens styled content on tap', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixPopover( + popoverChild: const Text('Popover content'), + style: RemixPopoverStyler() + .backgroundColor(Colors.purple) + .paddingAll(12), + child: const Text('Open popover'), + ), + ); + + expect(find.text('Open popover'), findsOneWidget); + expect(find.text('Popover content'), findsNothing); + + await tester.tap(find.text('Open popover')); + await tester.pumpAndSettle(); + + expect(find.text('Popover content'), findsOneWidget); + expect( + find.ancestor( + of: find.text('Popover content'), + matching: find.byType(Box), + ), + findsOneWidget, + ); + }); + + testWidgets('closes on an outside tap and reports lifecycle changes', ( + tester, + ) async { + var openCount = 0; + var closeCount = 0; + + await tester.pumpRemixApp( + RemixPopover( + popoverChild: const Text('Popover content'), + onOpen: () => openCount++, + onClose: () => closeCount++, + child: const Text('Open popover'), + ), + ); + + await tester.tap(find.text('Open popover')); + await tester.pumpAndSettle(); + + expect(openCount, 1); + expect(closeCount, 0); + + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + + expect(find.text('Popover content'), findsNothing); + expect(closeCount, 1); + }); + + testWidgets('supports programmatic control when tap opening is disabled', ( + tester, + ) async { + final controller = MenuController(); + + await tester.pumpRemixApp( + RemixPopover( + controller: controller, + openOnTap: false, + popoverChild: const Text('Popover content'), + child: const Text('Popover trigger'), + ), + ); + + await tester.tap(find.text('Popover trigger')); + await tester.pumpAndSettle(); + expect(find.text('Popover content'), findsNothing); + + controller.open(); + await tester.pumpAndSettle(); + expect(find.text('Popover content'), findsOneWidget); + + controller.close(); + await tester.pumpAndSettle(); + expect(find.text('Popover content'), findsNothing); + }); + + testWidgets('Escape closes the popover and restores trigger focus', ( + tester, + ) async { + final triggerFocusNode = FocusNode(debugLabel: 'popover trigger'); + addTearDown(triggerFocusNode.dispose); + + await tester.pumpRemixApp( + RemixPopover( + triggerFocusNode: triggerFocusNode, + popoverChild: const Text('Popover content'), + child: const Text('Open popover'), + ), + ); + + await tester.tap(find.text('Open popover')); + await tester.pumpAndSettle(); + expect(find.text('Popover content'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Popover content'), findsNothing); + expect(triggerFocusNode.hasFocus, isTrue); + }); + + testWidgets('forwards the trigger semantic label', (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + const RemixPopover( + semanticLabel: 'Show account details', + popoverChild: Text('Account details'), + child: Icon(Icons.person), + ), + ); + + expect(find.bySemanticsLabel('Show account details'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets( + 'controlled mode preserves child semantics and reports expanded state', + (tester) async { + final semantics = tester.ensureSemantics(); + final controller = MenuController(); + var activationCount = 0; + + await tester.pumpRemixApp( + RemixPopover( + controller: controller, + openOnTap: false, + semanticLabel: 'Configured popover label', + popoverChild: const Text('Controlled content'), + child: TextButton( + onPressed: () { + activationCount++; + controller.open(); + }, + child: const Text('Open controlled popover'), + ), + ), + ); + + final trigger = find.widgetWithText( + TextButton, + 'Open controlled popover', + ); + final semanticsTrigger = find.semantics.byLabel( + 'Open controlled popover', + ); + expect(semanticsTrigger, findsOne); + expect( + find.semantics.byLabel('Configured popover label'), + findsNothing, + ); + expect( + tester.getSemantics(trigger), + isSemantics( + label: 'Open controlled popover', + isButton: true, + hasTapAction: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + + tester.semantics.tap(semanticsTrigger); + await tester.pumpAndSettle(); + + expect(activationCount, 1); + expect(find.text('Controlled content'), findsOneWidget); + expect( + tester.getSemantics(trigger), + isSemantics(hasExpandedState: true, isExpanded: true), + ); + semantics.dispose(); + }, + ); + + testWidgets('reports collapsed and expanded trigger semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + const RemixPopover( + semanticLabel: 'Show account details', + popoverChild: Text('Account details'), + child: Icon(Icons.person), + ), + ); + + final trigger = find.bySemanticsLabel('Show account details'); + expect( + tester.getSemantics(trigger), + isSemantics( + label: 'Show account details', + isButton: true, + hasTapAction: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + + await tester.tap(trigger); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(trigger), + isSemantics(hasExpandedState: true, isExpanded: true), + ); + semantics.dispose(); + }); + + testWidgets('uses a raw style spec when supplied', (tester) async { + const rawContainer = StyleSpec( + spec: BoxSpec(constraints: BoxConstraints.tightFor(width: 240)), + ); + + await tester.pumpRemixApp( + const RemixPopover( + styleSpec: RemixPopoverSpec(container: rawContainer), + popoverChild: Text('Raw styled content'), + child: Text('Open popover'), + ), + ); + + await tester.tap(find.text('Open popover')); + await tester.pumpAndSettle(); + + final box = tester.widget( + find.ancestor( + of: find.text('Raw styled content'), + matching: find.byType(Box), + ), + ); + expect(box.styleSpec, same(rawContainer)); + }); + + testWidgets('FortalPopover supplies the themed overlay style', ( + tester, + ) async { + await tester.pumpRemixApp( + const FortalPopover( + popoverChild: Text('Fortal content'), + child: Text('Open Fortal popover'), + ), + ); + + await tester.tap(find.text('Open Fortal popover')); + await tester.pumpAndSettle(); + + expect(find.text('Fortal content'), findsOneWidget); + expect( + find.ancestor( + of: find.text('Fortal content'), + matching: find.byType(Box), + ), + findsOneWidget, + ); + }); + }); +}