Commit ccad2849 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Docs for menus (#10396)

Also, clean up the menus code a bit.

Also, make it easier to write a PopupMenuEntry that has itself many
items (for example, the way Chrome's menu has icons in a row).
parent c713aa00
...@@ -79,9 +79,9 @@ class MenuDemoState extends State<MenuDemo> { ...@@ -79,9 +79,9 @@ class MenuDemoState extends State<MenuDemo> {
value: 'Hooray!', value: 'Hooray!',
child: const Text('Hooray!') child: const Text('Hooray!')
), ),
] ],
) ),
] ],
), ),
body: new ListView( body: new ListView(
padding: kMaterialListPadding, padding: kMaterialListPadding,
......
...@@ -21,6 +21,7 @@ const double _kBaselineOffsetFromBottom = 20.0; ...@@ -21,6 +21,7 @@ const double _kBaselineOffsetFromBottom = 20.0;
const double _kMenuCloseIntervalEnd = 2.0 / 3.0; const double _kMenuCloseIntervalEnd = 2.0 / 3.0;
const double _kMenuHorizontalPadding = 16.0; const double _kMenuHorizontalPadding = 16.0;
const double _kMenuItemHeight = 48.0; const double _kMenuItemHeight = 48.0;
const double _kMenuDividerHeight = 16.0;
const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep;
const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; const double _kMenuMinWidth = 2.0 * _kMenuWidthStep;
const double _kMenuVerticalPadding = 8.0; const double _kMenuVerticalPadding = 8.0;
...@@ -33,16 +34,21 @@ const double _kMenuScreenPadding = 8.0; ...@@ -33,16 +34,21 @@ const double _kMenuScreenPadding = 8.0;
/// To show a popup menu, use the [showMenu] function. To create a button that /// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton]. /// shows a popup menu, consider using [PopupMenuButton].
/// ///
/// The type `T` is the type of the value the entry represents. All the entries /// The type `T` is the type of the value(s) the entry represents. All the
/// in a given menu must represent values with consistent types. /// entries in a given menu must represent values with consistent types.
///
/// A [PopupMenuEntry] may represent multiple values, for example a row with
/// several icons, or a single entry, for example a menu item with an icon (see
/// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]).
/// ///
/// See also: /// See also:
/// ///
/// * [PopupMenuItem] /// * [PopupMenuItem], a popup menu entry for a single value.
/// * [PopupMenuDivider] /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
/// * [CheckedPopupMenuItem] /// * [CheckedPopupMenuItem], a popup menu item with a checkmark.
/// * [showMenu] /// * [showMenu], a method to dynamically show a popup menu at a given location.
/// * [PopupMenuButton] /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when
/// it is tapped.
abstract class PopupMenuEntry<T> extends StatefulWidget { abstract class PopupMenuEntry<T> extends StatefulWidget {
/// Abstract const constructor. This constructor enables subclasses to provide /// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions. /// const constructors so that they can be used in const expressions.
...@@ -50,36 +56,51 @@ abstract class PopupMenuEntry<T> extends StatefulWidget { ...@@ -50,36 +56,51 @@ abstract class PopupMenuEntry<T> extends StatefulWidget {
/// The amount of vertical space occupied by this entry. /// The amount of vertical space occupied by this entry.
/// ///
/// This value must remain constant for a given instance. /// This value is used at the time the [showMenu] method is called, if the
/// `initialValue` argument is provided, to determine the position of this
/// entry when aligning the selected entry over the given `position`. It is
/// otherwise ignored.
double get height; double get height;
/// The value that should be returned by [showMenu] when the user selects this entry. /// Whether this entry represents a particular value.
T get value => null; ///
/// This method is used by [showMenu], when it is called, to align the entry
/// Whether the user is permitted to select this entry. /// representing the `initialValue`, if any, to the given `position`, and then
bool get enabled; /// later is called on each entry to determine if it should be highlighted (if
/// the method returns true, the entry will have its background color set to
/// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then
/// this method is not called.
///
/// If the [PopupMenuEntry] represents a single value, this should return true
/// if the argument matches that value. If it represents multiple values, it
/// should return true if the argument matches any of them.
bool represents(T value);
} }
/// A horizontal divider in a material design popup menu. /// A horizontal divider in a material design popup menu.
/// ///
/// This widget adatps the [Divider] for use in popup menus. /// This widget adapts the [Divider] for use in popup menus.
/// ///
/// See also: /// See also:
/// ///
/// * [PopupMenuItem] /// * [PopupMenuItem], for the kinds of items that this widget divides.
/// * [showMenu] /// * [showMenu], a method to dynamically show a popup menu at a given location.
/// * [PopupMenuButton] /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when
class PopupMenuDivider extends PopupMenuEntry<dynamic> { /// it is tapped.
class PopupMenuDivider extends PopupMenuEntry<Null> {
/// Creates a horizontal divider for a popup menu. /// Creates a horizontal divider for a popup menu.
/// ///
/// By default, the divider has a height of 16.0 logical pixels. /// By default, the divider has a height of 16 logical pixels.
const PopupMenuDivider({ Key key, this.height: 16.0 }) : super(key: key); const PopupMenuDivider({ Key key, this.height: _kMenuDividerHeight }) : super(key: key);
/// The height of the divider entry.
///
/// Defaults to 16 pixels.
@override @override
final double height; final double height;
@override @override
bool get enabled => false; bool represents(dynamic value) => false;
@override @override
_PopupMenuDividerState createState() => new _PopupMenuDividerState(); _PopupMenuDividerState createState() => new _PopupMenuDividerState();
...@@ -98,34 +119,76 @@ class _PopupMenuDividerState extends State<PopupMenuDivider> { ...@@ -98,34 +119,76 @@ class _PopupMenuDividerState extends State<PopupMenuDivider> {
/// To show a checkmark next to a popup menu item, consider using /// To show a checkmark next to a popup menu item, consider using
/// [CheckedPopupMenuItem]. /// [CheckedPopupMenuItem].
/// ///
/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More
/// elaborate menus with icons can use a [ListTile]. By default, a
/// [PopupMenuItem] is 48 pixels high. If you use a widget with a different
/// height, it must be specified in the [height] property.
///
/// ## Sample code
///
/// Here, a [Text] widget is used with a popup menu item. The `WhyFarther` type
/// is an enum, not shown here.
///
/// ```dart
/// const PopupMenuItem<WhyFarther>(
/// value: WhyFarther.harder,
/// child: const Text('Working a lot harder'),
/// ),
/// ```
///
/// See the example at [PopupMenuButton] for how this example could be used in a
/// complete menu, and see the example at [CheckedPopupMenuItem] for one way to
/// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child]
/// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem]
/// that use a [ListTile] in their [child] slot.
///
/// See also: /// See also:
/// ///
/// * [PopupMenuDivider] /// * [PopupMenuDivider], which can be used to divide items from each other.
/// * [CheckedPopupMenuItem] /// * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark.
/// * [showMenu] /// * [showMenu], a method to dynamically show a popup menu at a given location.
/// * [PopupMenuButton] /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when
/// it is tapped.
class PopupMenuItem<T> extends PopupMenuEntry<T> { class PopupMenuItem<T> extends PopupMenuEntry<T> {
/// Creates an item for a popup menu. /// Creates an item for a popup menu.
/// ///
/// By default, the item is enabled. /// By default, the item is [enabled].
///
/// The `height` and `enabled` arguments must not be null.
const PopupMenuItem({ const PopupMenuItem({
Key key, Key key,
this.value, this.value,
this.enabled: true, this.enabled: true,
this.height: _kMenuItemHeight,
@required this.child, @required this.child,
}) : super(key: key); }) : assert(enabled != null),
assert(height != null),
super(key: key);
@override /// The value that will be returned by [showMenu] if this entry is selected.
final T value; final T value;
@override /// Whether the user is permitted to select this entry.
///
/// Defaults to true. If this is false, then the item will not react to
/// touches.
final bool enabled; final bool enabled;
/// The height of the entry.
///
/// Defaults to 48 pixels.
@override
final double height;
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
///
/// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An
/// appropriate [DefaultTextStyle] is put in scope for the child. In either
/// case, the text should be short enough that it won't wrap.
final Widget child; final Widget child;
@override @override
double get height => _kMenuItemHeight; bool represents(T value) => value == this.value;
@override @override
_PopupMenuItemState<PopupMenuItem<T>> createState() => new _PopupMenuItemState<PopupMenuItem<T>>(); _PopupMenuItemState<PopupMenuItem<T>> createState() => new _PopupMenuItemState<PopupMenuItem<T>>();
...@@ -135,7 +198,7 @@ class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> { ...@@ -135,7 +198,7 @@ class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> {
// Override this to put something else in the menu entry. // Override this to put something else in the menu entry.
Widget buildChild() => widget.child; Widget buildChild() => widget.child;
void onTap() { void handleTap() {
Navigator.pop(context, widget.value); Navigator.pop(context, widget.value);
} }
...@@ -152,24 +215,24 @@ class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> { ...@@ -152,24 +215,24 @@ class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> {
child: new Baseline( child: new Baseline(
baseline: widget.height - _kBaselineOffsetFromBottom, baseline: widget.height - _kBaselineOffsetFromBottom,
baselineType: TextBaseline.alphabetic, baselineType: TextBaseline.alphabetic,
child: buildChild() child: buildChild(),
) )
); );
if (!widget.enabled) { if (!widget.enabled) {
final bool isDark = theme.brightness == Brightness.dark; final bool isDark = theme.brightness == Brightness.dark;
item = IconTheme.merge( item = IconTheme.merge(
data: new IconThemeData(opacity: isDark ? 0.5 : 0.38), data: new IconThemeData(opacity: isDark ? 0.5 : 0.38),
child: item child: item,
); );
} }
return new InkWell( return new InkWell(
onTap: widget.enabled ? onTap : null, onTap: widget.enabled ? handleTap : null,
child: new MergeSemantics( child: new MergeSemantics(
child: new Container( child: new Container(
height: widget.height, height: widget.height,
padding: const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding), padding: const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding),
child: item child: item,
) )
) )
); );
...@@ -181,33 +244,102 @@ class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> { ...@@ -181,33 +244,102 @@ class _PopupMenuItemState<T extends PopupMenuItem<dynamic>> extends State<T> {
/// To show a popup menu, use the [showMenu] function. To create a button that /// To show a popup menu, use the [showMenu] function. To create a button that
/// shows a popup menu, consider using [PopupMenuButton]. /// shows a popup menu, consider using [PopupMenuButton].
/// ///
/// A [CheckedPopupMenuItem] is 48 pixels high, which matches the default height
/// of a [PopupMenuItem]. The horizontal layout uses a [ListTile]; the checkmark
/// is an [Icons.done] icon, shown in the [ListTile.leading] position.
///
/// ## Sample code
///
/// Suppose a `Commands` enum exists that lists the possible commands from a
/// particular popup menu, including `Commands.heroAndScholar` and
/// `Commands.hurricaneCame`, and further suppose that there is a
/// `_heroAndScholar` member field which is a boolean. The example below shows a
/// menu with one menu item with a checkmark that can toggle the boolean, and
/// one menu item without a checkmark for selecting the second option. (It also
/// shows a divider placed between the two menu items.)
///
/// ```dart
/// new PopupMenuButton<Commands>(
/// onSelected: (Commands result) {
/// switch (result) {
/// case Commands.heroAndScholar:
/// setState(() { _heroAndScholar = !_heroAndScholar; });
/// break;
/// case Commands.hurricaneCame:
/// // ...handle hurricane option
/// break;
/// // ...other items handled here
/// }
/// },
/// itemBuilder: (BuildContext context) => <PopupMenuEntry<Commands>>[
/// new CheckedPopupMenuItem<Commands>(
/// checked: _heroAndScholar,
/// value: Commands.heroAndScholar,
/// child: const Text('Hero and scholar'),
/// ),
/// const PopupMenuDivider(),
/// const PopupMenuItem<Commands>(
/// value: Commands.hurricaneCame,
/// child: const ListTile(leading: const Icon(null), title: const Text('Bring hurricane')),
/// ),
/// // ...other items listed here
/// ],
/// )
/// ```
///
/// In particular, observe how the second menu item uses a [ListTile] with a
/// blank [Icon] in the [ListTile.leading] position to get the same alignment as
/// the item with the checkmark.
///
/// See also: /// See also:
/// ///
/// * [PopupMenuItem] /// * [PopupMenuItem], a popup menu entry for picking a command (as opposed to
/// * [PopupMenuDivider] /// toggling a value).
/// * [CheckedPopupMenuItem] /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
/// * [showMenu] /// * [showMenu], a method to dynamically show a popup menu at a given location.
/// * [PopupMenuButton] /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when
/// it is tapped.
class CheckedPopupMenuItem<T> extends PopupMenuItem<T> { class CheckedPopupMenuItem<T> extends PopupMenuItem<T> {
/// Creates a popup menu item with a checkmark. /// Creates a popup menu item with a checkmark.
/// ///
/// By default, the menu item is enabled but unchecked. /// By default, the menu item is [enabled] but unchecked. To mark the item as
/// checked, set [checked] to true.
///
/// The `checked` and `enabled` arguments must not be null.
const CheckedPopupMenuItem({ const CheckedPopupMenuItem({
Key key, Key key,
T value, T value,
this.checked: false, this.checked: false,
bool enabled: true, bool enabled: true,
Widget child Widget child,
}) : super( }) : assert(checked != null),
super(
key: key, key: key,
value: value, value: value,
enabled: enabled, enabled: enabled,
child: child child: child,
); );
/// Whether to display a checkmark next to the menu item. /// Whether to display a checkmark next to the menu item.
///
/// Defaults to false.
///
/// When true, an [Icons.done] checkmark is displayed.
///
/// When this popup menu item is selected, the checkmark will fade in or out
/// as appropriate to represent the implied new state.
final bool checked; final bool checked;
/// The widget below this widget in the tree.
///
/// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for
/// the child. The text should be short enough that it won't wrap.
///
/// This widget is placed in the [ListTile.title] slot of a [ListTile] whose
/// [ListTile.leading] slot is an [Icons.done] icon.
@override
Widget get child => super.child;
@override @override
_CheckedPopupMenuItemState<T> createState() => new _CheckedPopupMenuItemState<T>(); _CheckedPopupMenuItemState<T> createState() => new _CheckedPopupMenuItemState<T>();
} }
...@@ -226,13 +358,13 @@ class _CheckedPopupMenuItemState<T> extends _PopupMenuItemState<CheckedPopupMenu ...@@ -226,13 +358,13 @@ class _CheckedPopupMenuItemState<T> extends _PopupMenuItemState<CheckedPopupMenu
} }
@override @override
void onTap() { void handleTap() {
// This fades the checkmark in or out when tapped. // This fades the checkmark in or out when tapped.
if (widget.checked) if (widget.checked)
_controller.reverse(); _controller.reverse();
else else
_controller.forward(); _controller.forward();
super.onTap(); super.handleTap();
} }
@override @override
...@@ -243,7 +375,7 @@ class _CheckedPopupMenuItemState<T> extends _PopupMenuItemState<CheckedPopupMenu ...@@ -243,7 +375,7 @@ class _CheckedPopupMenuItemState<T> extends _PopupMenuItemState<CheckedPopupMenu
opacity: _opacity, opacity: _opacity,
child: new Icon(_controller.isDismissed ? null : Icons.done) child: new Icon(_controller.isDismissed ? null : Icons.done)
), ),
title: widget.child title: widget.child,
); );
} }
} }
...@@ -261,7 +393,7 @@ class _PopupMenu<T> extends StatelessWidget { ...@@ -261,7 +393,7 @@ class _PopupMenu<T> extends StatelessWidget {
final double unit = 1.0 / (route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade. final double unit = 1.0 / (route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade.
final List<Widget> children = <Widget>[]; final List<Widget> children = <Widget>[];
for (int i = 0; i < route.items.length; ++i) { for (int i = 0; i < route.items.length; i += 1) {
final double start = (i + 1) * unit; final double start = (i + 1) * unit;
final double end = (start + 1.5 * unit).clamp(0.0, 1.0); final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
final CurvedAnimation opacity = new CurvedAnimation( final CurvedAnimation opacity = new CurvedAnimation(
...@@ -269,15 +401,15 @@ class _PopupMenu<T> extends StatelessWidget { ...@@ -269,15 +401,15 @@ class _PopupMenu<T> extends StatelessWidget {
curve: new Interval(start, end) curve: new Interval(start, end)
); );
Widget item = route.items[i]; Widget item = route.items[i];
if (route.initialValue != null && route.initialValue == route.items[i].value) { if (route.initialValue != null && route.items[i].represents(route.initialValue)) {
item = new Container( item = new Container(
color: Theme.of(context).highlightColor, color: Theme.of(context).highlightColor,
child: item child: item,
); );
} }
children.add(new FadeTransition( children.add(new FadeTransition(
opacity: opacity, opacity: opacity,
child: item child: item,
)); ));
} }
...@@ -318,7 +450,7 @@ class _PopupMenu<T> extends StatelessWidget { ...@@ -318,7 +450,7 @@ class _PopupMenu<T> extends StatelessWidget {
), ),
); );
}, },
child: child child: child,
); );
} }
} }
...@@ -345,7 +477,7 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { ...@@ -345,7 +477,7 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate {
?? (position?.bottom != null ? size.height - (position.bottom - childSize.height) : _kMenuScreenPadding); ?? (position?.bottom != null ? size.height - (position.bottom - childSize.height) : _kMenuScreenPadding);
if (selectedItemOffset != null) if (selectedItemOffset != null)
y -= selectedItemOffset + _kMenuVerticalPadding + _kMenuItemHeight / 2.0; y -= selectedItemOffset + _kMenuVerticalPadding;
if (x < _kMenuScreenPadding) if (x < _kMenuScreenPadding)
x = _kMenuScreenPadding; x = _kMenuScreenPadding;
...@@ -402,10 +534,12 @@ class _PopupMenuRoute<T> extends PopupRoute<T> { ...@@ -402,10 +534,12 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
double selectedItemOffset; double selectedItemOffset;
if (initialValue != null) { if (initialValue != null) {
selectedItemOffset = 0.0; selectedItemOffset = 0.0;
for (int i = 0; i < items.length; i++) { for (PopupMenuEntry<T> entry in items) {
if (initialValue == items[i].value) if (entry.represents(initialValue)) {
selectedItemOffset += entry.height / 2.0;
break; break;
selectedItemOffset += items[i].height; }
selectedItemOffset += entry.height;
} }
} }
...@@ -415,7 +549,7 @@ class _PopupMenuRoute<T> extends PopupRoute<T> { ...@@ -415,7 +549,7 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
return new CustomSingleChildLayout( return new CustomSingleChildLayout(
delegate: new _PopupMenuRouteLayout(position, selectedItemOffset), delegate: new _PopupMenuRouteLayout(position, selectedItemOffset),
child: menu child: menu,
); );
} }
} }
...@@ -432,6 +566,20 @@ class _PopupMenuRoute<T> extends PopupRoute<T> { ...@@ -432,6 +566,20 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
/// The `elevation` argument specifies the z-coordinate at which to place the /// The `elevation` argument specifies the z-coordinate at which to place the
/// menu. The elevation defaults to 8, the appropriate elevation for popup /// menu. The elevation defaults to 8, the appropriate elevation for popup
/// menus. /// menus.
///
/// The positioning of the `initialValue` at the `position` is implemented by
/// iterating over the `items` to find the first whose
/// [PopupMenuEntry.represents] method returns true for `initialValue`, and then
/// summing the values of [PopupMenuEntry.height] for all the preceding widgets
/// in the list.
///
/// See also:
///
/// * [PopupMenuItem], a popup menu entry for a single value.
/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
/// * [CheckedPopupMenuItem], a popup menu item with a checkmark.
/// * [PopupMenuButton], which provides an [IconButton] that shows a menu by
/// calling this method automatically.
Future<T> showMenu<T>({ Future<T> showMenu<T>({
@required BuildContext context, @required BuildContext context,
RelativeRect position, RelativeRect position,
...@@ -467,6 +615,45 @@ typedef List<PopupMenuEntry<T>> PopupMenuItemBuilder<T>(BuildContext context); ...@@ -467,6 +615,45 @@ typedef List<PopupMenuEntry<T>> PopupMenuItemBuilder<T>(BuildContext context);
/// because an item was selected. The value passed to [onSelected] is the value of /// because an item was selected. The value passed to [onSelected] is the value of
/// the selected menu item. If child is null then a standard 'navigation/more_vert' /// the selected menu item. If child is null then a standard 'navigation/more_vert'
/// icon is created. /// icon is created.
///
/// This example shows a menu with four items, selecting between an enum's
/// values and setting a `_selection` field based on the selection.
///
/// ```dart
/// // This is the type used by the popup menu below.
/// enum WhyFarther { harder, smarter, selfStarter, tradingCharter }
///
/// // This menu button widget updates a _selection field (of type WhyFarther,
/// // not shown here).
/// new PopupMenuButton<WhyFarther>(
/// onSelected: (WhyFarther result) { setState(() { _selection = result; }); },
/// itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[
/// const PopupMenuItem<WhyFarther>(
/// value: WhyFarther.harder,
/// child: const Text('Working a lot harder'),
/// ),
/// const PopupMenuItem<WhyFarther>(
/// value: WhyFarther.smarter,
/// child: const Text('Being a lot smarter'),
/// ),
/// const PopupMenuItem<WhyFarther>(
/// value: WhyFarther.selfStarter,
/// child: const Text('Being a self-starter'),
/// ),
/// const PopupMenuItem<WhyFarther>(
/// value: WhyFarther.tradingCharter,
/// child: const Text('Placed in charge of trading charter'),
/// ),
/// ],
/// )
/// ```
///
/// See also:
///
/// * [PopupMenuItem], a popup menu entry for a single value.
/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line.
/// * [CheckedPopupMenuItem], a popup menu item with a checkmark.
/// * [showMenu], a method to dynamically show a popup menu at a given location.
class PopupMenuButton<T> extends StatefulWidget { class PopupMenuButton<T> extends StatefulWidget {
/// Creates a button that shows a popup menu. /// Creates a button that shows a popup menu.
/// ///
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment