Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@
"title": "Menu",
"href": "/components/menu"
},
{
"title": "Popover",
"href": "/components/popover"
},
{
"title": "Progress",
"href": "/components/progress"
Expand Down
158 changes: 158 additions & 0 deletions docs/components/popover.mdx
Original file line number Diff line number Diff line change
@@ -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`.
71 changes: 71 additions & 0 deletions packages/demo/lib/components/popover.dart
Original file line number Diff line number Diff line change
@@ -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'),
],
),
),
),
),
),
);
}
10 changes: 10 additions & 0 deletions packages/demo/lib/main.directories.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions packages/remix/lib/remix.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
);
}
16 changes: 16 additions & 0 deletions packages/remix/lib/src/components/popover/popover.dart
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading