← Back to Blog

Flutter PopupMenu Localization: Context Menus, Dropdown Actions, and Overflow Menus

flutterpopupmenucontext-menulocalizationdropdownactions

Flutter PopupMenu Localization: Context Menus, Dropdown Actions, and Overflow Menus

PopupMenus are essential for providing contextual actions throughout Flutter applications. From overflow menus in app bars to long-press context menus, properly localizing menu content ensures users can understand and interact with your action options. This guide covers all popup menu variants and their localization requirements.

Understanding PopupMenu Components

PopupMenus have several localizable elements:

  • Menu items: Action labels
  • Icons: Semantic descriptions
  • Dividers with headers: Section labels
  • Tooltips: Long-press descriptions
  • Submenus: Nested action labels

Basic PopupMenu Localization

Let's start with a simple localized popup menu:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedPopupMenu extends StatelessWidget {
  const LocalizedPopupMenu({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return PopupMenuButton<String>(
      tooltip: l10n.moreOptions,
      onSelected: (value) => _handleMenuSelection(context, value),
      itemBuilder: (context) => [
        PopupMenuItem(
          value: 'edit',
          child: ListTile(
            leading: Icon(Icons.edit, semanticLabel: l10n.edit),
            title: Text(l10n.edit),
            contentPadding: EdgeInsets.zero,
          ),
        ),
        PopupMenuItem(
          value: 'share',
          child: ListTile(
            leading: Icon(Icons.share, semanticLabel: l10n.share),
            title: Text(l10n.share),
            contentPadding: EdgeInsets.zero,
          ),
        ),
        const PopupMenuDivider(),
        PopupMenuItem(
          value: 'delete',
          child: ListTile(
            leading: Icon(
              Icons.delete,
              color: Colors.red,
              semanticLabel: l10n.delete,
            ),
            title: Text(
              l10n.delete,
              style: const TextStyle(color: Colors.red),
            ),
            contentPadding: EdgeInsets.zero,
          ),
        ),
      ],
    );
  }

  void _handleMenuSelection(BuildContext context, String value) {
    final l10n = AppLocalizations.of(context)!;

    switch (value) {
      case 'edit':
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(l10n.editingItem)),
        );
        break;
      case 'share':
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(l10n.sharingItem)),
        );
        break;
      case 'delete':
        _showDeleteConfirmation(context);
        break;
    }
  }

  void _showDeleteConfirmation(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.deleteConfirmTitle),
        content: Text(l10n.deleteConfirmMessage),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text(l10n.cancel),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context),
            style: TextButton.styleFrom(foregroundColor: Colors.red),
            child: Text(l10n.delete),
          ),
        ],
      ),
    );
  }
}

ARB Translations for PopupMenus

Define comprehensive menu translations:

{
  "moreOptions": "More options",
  "edit": "Edit",
  "share": "Share",
  "delete": "Delete",
  "copy": "Copy",
  "paste": "Paste",
  "cut": "Cut",
  "selectAll": "Select All",
  "duplicate": "Duplicate",
  "rename": "Rename",
  "moveToFolder": "Move to Folder",
  "addToFavorites": "Add to Favorites",
  "removeFromFavorites": "Remove from Favorites",
  "archive": "Archive",
  "report": "Report",

  "editingItem": "Opening editor...",
  "sharingItem": "Opening share sheet...",
  "deleteConfirmTitle": "Delete Item?",
  "deleteConfirmMessage": "This action cannot be undone.",
  "cancel": "Cancel",

  "sortBy": "Sort by",
  "sortByName": "Name",
  "sortByDate": "Date",
  "sortBySize": "Size",
  "sortByType": "Type",

  "viewAs": "View as",
  "viewAsList": "List",
  "viewAsGrid": "Grid",
  "viewAsCompact": "Compact"
}

AppBar Overflow Menu

Implement localized overflow menu:

class LocalizedAppBar extends StatelessWidget implements PreferredSizeWidget {
  const LocalizedAppBar({super.key});

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return AppBar(
      title: Text(l10n.appTitle),
      actions: [
        IconButton(
          icon: const Icon(Icons.search),
          tooltip: l10n.search,
          onPressed: () {},
        ),
        PopupMenuButton<String>(
          tooltip: l10n.moreOptions,
          onSelected: (value) => _handleAction(context, value),
          itemBuilder: (context) => [
            PopupMenuItem(
              value: 'settings',
              child: Row(
                children: [
                  const Icon(Icons.settings),
                  const SizedBox(width: 12),
                  Text(l10n.settings),
                ],
              ),
            ),
            PopupMenuItem(
              value: 'help',
              child: Row(
                children: [
                  const Icon(Icons.help),
                  const SizedBox(width: 12),
                  Text(l10n.help),
                ],
              ),
            ),
            PopupMenuItem(
              value: 'about',
              child: Row(
                children: [
                  const Icon(Icons.info),
                  const SizedBox(width: 12),
                  Text(l10n.about),
                ],
              ),
            ),
          ],
        ),
      ],
    );
  }

  void _handleAction(BuildContext context, String value) {
    // Handle menu selection
  }
}

Context Menu on Long Press

Implement localized context menus:

class LocalizedContextMenu extends StatelessWidget {
  final Widget child;
  final VoidCallback? onEdit;
  final VoidCallback? onCopy;
  final VoidCallback? onDelete;

  const LocalizedContextMenu({
    super.key,
    required this.child,
    this.onEdit,
    this.onCopy,
    this.onDelete,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return GestureDetector(
      onLongPressStart: (details) {
        _showContextMenu(context, details.globalPosition);
      },
      child: child,
    );
  }

  void _showContextMenu(BuildContext context, Offset position) {
    final l10n = AppLocalizations.of(context)!;
    final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;

    showMenu<String>(
      context: context,
      position: RelativeRect.fromRect(
        Rect.fromLTWH(position.dx, position.dy, 0, 0),
        Offset.zero & overlay.size,
      ),
      items: [
        if (onEdit != null)
          PopupMenuItem(
            value: 'edit',
            child: Row(
              children: [
                Icon(Icons.edit, semanticLabel: l10n.edit),
                const SizedBox(width: 12),
                Text(l10n.edit),
              ],
            ),
          ),
        if (onCopy != null)
          PopupMenuItem(
            value: 'copy',
            child: Row(
              children: [
                Icon(Icons.copy, semanticLabel: l10n.copy),
                const SizedBox(width: 12),
                Text(l10n.copy),
              ],
            ),
          ),
        if (onDelete != null) ...[
          const PopupMenuDivider(),
          PopupMenuItem(
            value: 'delete',
            child: Row(
              children: [
                Icon(Icons.delete, color: Colors.red, semanticLabel: l10n.delete),
                const SizedBox(width: 12),
                Text(l10n.delete, style: const TextStyle(color: Colors.red)),
              ],
            ),
          ),
        ],
      ],
    ).then((value) {
      if (value == null) return;

      switch (value) {
        case 'edit': onEdit?.call(); break;
        case 'copy': onCopy?.call(); break;
        case 'delete': onDelete?.call(); break;
      }
    });
  }
}

Submenu Implementation

Handle nested menus with localization:

class LocalizedSubmenu extends StatelessWidget {
  const LocalizedSubmenu({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return PopupMenuButton<String>(
      tooltip: l10n.moreOptions,
      itemBuilder: (context) => [
        PopupMenuItem(
          value: 'new',
          child: Row(
            children: [
              const Icon(Icons.add),
              const SizedBox(width: 12),
              Text(l10n.createNew),
            ],
          ),
        ),
        // Submenu using SubmenuButton (Flutter 3.10+)
        SubmenuButton(
          menuChildren: [
            MenuItemButton(
              onPressed: () {},
              child: Text(l10n.sortByName),
            ),
            MenuItemButton(
              onPressed: () {},
              child: Text(l10n.sortByDate),
            ),
            MenuItemButton(
              onPressed: () {},
              child: Text(l10n.sortBySize),
            ),
          ],
          child: Row(
            children: [
              const Icon(Icons.sort),
              const SizedBox(width: 12),
              Text(l10n.sortBy),
              const Spacer(),
              const Icon(Icons.arrow_right),
            ],
          ),
        ),
        SubmenuButton(
          menuChildren: [
            MenuItemButton(
              onPressed: () {},
              leadingIcon: const Icon(Icons.list),
              child: Text(l10n.viewAsList),
            ),
            MenuItemButton(
              onPressed: () {},
              leadingIcon: const Icon(Icons.grid_view),
              child: Text(l10n.viewAsGrid),
            ),
          ],
          child: Row(
            children: [
              const Icon(Icons.view_module),
              const SizedBox(width: 12),
              Text(l10n.viewAs),
              const Spacer(),
              const Icon(Icons.arrow_right),
            ],
          ),
        ),
      ],
    );
  }
}

RTL Support for PopupMenus

Handle RTL layout correctly:

class RTLAwarePopupMenu extends StatelessWidget {
  const RTLAwarePopupMenu({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isRTL = Directionality.of(context) == TextDirection.rtl;

    return PopupMenuButton<String>(
      tooltip: l10n.moreOptions,
      // Position menu based on text direction
      offset: Offset(isRTL ? 0 : -100, 0),
      itemBuilder: (context) => [
        PopupMenuItem(
          value: 'edit',
          child: Row(
            // Icons and text order handled automatically by Row in RTL
            children: [
              Icon(Icons.edit, semanticLabel: l10n.edit),
              const SizedBox(width: 12),
              Expanded(child: Text(l10n.edit)),
              // Show keyboard shortcut
              Text(
                isRTL ? 'E + Ctrl' : 'Ctrl + E',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
          ),
        ),
        PopupMenuItem(
          value: 'copy',
          child: Row(
            children: [
              Icon(Icons.copy, semanticLabel: l10n.copy),
              const SizedBox(width: 12),
              Expanded(child: Text(l10n.copy)),
              Text(
                isRTL ? 'C + Ctrl' : 'Ctrl + C',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

Checkbox and Radio Menu Items

Localized selection menus:

class LocalizedSelectionMenu extends StatefulWidget {
  const LocalizedSelectionMenu({super.key});

  @override
  State<LocalizedSelectionMenu> createState() => _LocalizedSelectionMenuState();
}

class _LocalizedSelectionMenuState extends State<LocalizedSelectionMenu> {
  String _sortBy = 'name';
  bool _showHidden = false;
  bool _showThumbnails = true;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return PopupMenuButton<void>(
      tooltip: l10n.viewOptions,
      itemBuilder: (context) => [
        // Section header
        PopupMenuItem(
          enabled: false,
          child: Text(
            l10n.sortBy,
            style: Theme.of(context).textTheme.titleSmall,
          ),
        ),
        CheckedPopupMenuItem(
          value: null,
          checked: _sortBy == 'name',
          child: Text(l10n.sortByName),
        ),
        CheckedPopupMenuItem(
          value: null,
          checked: _sortBy == 'date',
          child: Text(l10n.sortByDate),
        ),
        CheckedPopupMenuItem(
          value: null,
          checked: _sortBy == 'size',
          child: Text(l10n.sortBySize),
        ),
        const PopupMenuDivider(),
        // Toggle options section
        PopupMenuItem(
          enabled: false,
          child: Text(
            l10n.displayOptions,
            style: Theme.of(context).textTheme.titleSmall,
          ),
        ),
        PopupMenuItem(
          child: StatefulBuilder(
            builder: (context, setState) {
              return CheckboxListTile(
                title: Text(l10n.showHiddenFiles),
                value: _showHidden,
                onChanged: (value) {
                  setState(() => _showHidden = value ?? false);
                  this.setState(() {});
                },
                contentPadding: EdgeInsets.zero,
              );
            },
          ),
        ),
        PopupMenuItem(
          child: StatefulBuilder(
            builder: (context, setState) {
              return CheckboxListTile(
                title: Text(l10n.showThumbnails),
                value: _showThumbnails,
                onChanged: (value) {
                  setState(() => _showThumbnails = value ?? true);
                  this.setState(() {});
                },
                contentPadding: EdgeInsets.zero,
              );
            },
          ),
        ),
      ],
    );
  }
}

Accessibility for PopupMenus

Ensure menus are accessible:

class AccessiblePopupMenu extends StatelessWidget {
  const AccessiblePopupMenu({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Semantics(
      label: l10n.actionMenu,
      hint: l10n.actionMenuHint,
      button: true,
      child: PopupMenuButton<String>(
        tooltip: l10n.moreOptions,
        onSelected: (value) => _announceSelection(context, value),
        itemBuilder: (context) => [
          PopupMenuItem(
            value: 'edit',
            child: Semantics(
              label: l10n.editAction,
              hint: l10n.editActionHint,
              child: Row(
                children: [
                  ExcludeSemantics(child: const Icon(Icons.edit)),
                  const SizedBox(width: 12),
                  Text(l10n.edit),
                ],
              ),
            ),
          ),
          PopupMenuItem(
            value: 'share',
            child: Semantics(
              label: l10n.shareAction,
              hint: l10n.shareActionHint,
              child: Row(
                children: [
                  ExcludeSemantics(child: const Icon(Icons.share)),
                  const SizedBox(width: 12),
                  Text(l10n.share),
                ],
              ),
            ),
          ),
          PopupMenuItem(
            value: 'delete',
            child: Semantics(
              label: l10n.deleteAction,
              hint: l10n.deleteActionHint,
              child: Row(
                children: [
                  ExcludeSemantics(
                    child: const Icon(Icons.delete, color: Colors.red),
                  ),
                  const SizedBox(width: 12),
                  Text(
                    l10n.delete,
                    style: const TextStyle(color: Colors.red),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _announceSelection(BuildContext context, String value) {
    final l10n = AppLocalizations.of(context)!;

    // Announce selection for screen readers
    SemanticsService.announce(
      l10n.selectedAction(value),
      Directionality.of(context),
    );
  }
}

Complete ARB File for PopupMenus

{
  "moreOptions": "More options",
  "actionMenu": "Action menu",
  "actionMenuHint": "Double tap to open menu with available actions",

  "edit": "Edit",
  "editAction": "Edit item",
  "editActionHint": "Opens the item for editing",
  "editingItem": "Opening editor...",

  "share": "Share",
  "shareAction": "Share item",
  "shareActionHint": "Opens sharing options",
  "sharingItem": "Opening share sheet...",

  "delete": "Delete",
  "deleteAction": "Delete item",
  "deleteActionHint": "Permanently removes this item",
  "deleteConfirmTitle": "Delete Item?",
  "deleteConfirmMessage": "This action cannot be undone. Are you sure you want to delete this item?",

  "copy": "Copy",
  "paste": "Paste",
  "cut": "Cut",
  "selectAll": "Select All",
  "duplicate": "Duplicate",
  "rename": "Rename",

  "cancel": "Cancel",
  "confirm": "Confirm",

  "sortBy": "Sort by",
  "sortByName": "Name",
  "sortByDate": "Date modified",
  "sortBySize": "Size",
  "sortByType": "Type",

  "viewAs": "View as",
  "viewAsList": "List",
  "viewAsGrid": "Grid",
  "viewAsCompact": "Compact",

  "viewOptions": "View options",
  "displayOptions": "Display",
  "showHiddenFiles": "Show hidden files",
  "showThumbnails": "Show thumbnails",

  "createNew": "Create new",
  "settings": "Settings",
  "help": "Help",
  "about": "About",
  "search": "Search",
  "appTitle": "My App",

  "selectedAction": "{action} selected",
  "@selectedAction": {
    "description": "Announced when menu item is selected",
    "placeholders": {
      "action": {
        "type": "String",
        "example": "Edit"
      }
    }
  }
}

Testing PopupMenu Localization

Test your localized popup menus:

void main() {
  group('PopupMenu Localization Tests', () {
    testWidgets('displays localized menu items', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('de'),
          home: Scaffold(
            appBar: AppBar(
              actions: const [LocalizedPopupMenu()],
            ),
          ),
        ),
      );

      // Open menu
      await tester.tap(find.byType(PopupMenuButton<String>));
      await tester.pumpAndSettle();

      // Verify German translations
      expect(find.text('Bearbeiten'), findsOneWidget);
      expect(find.text('Teilen'), findsOneWidget);
      expect(find.text('Löschen'), findsOneWidget);
    });

    testWidgets('handles menu selection', (tester) async {
      String? selectedValue;

      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(
            body: PopupMenuButton<String>(
              onSelected: (value) => selectedValue = value,
              itemBuilder: (context) {
                final l10n = AppLocalizations.of(context)!;
                return [
                  PopupMenuItem(value: 'edit', child: Text(l10n.edit)),
                ];
              },
            ),
          ),
        ),
      );

      await tester.tap(find.byType(PopupMenuButton<String>));
      await tester.pumpAndSettle();
      await tester.tap(find.text('Edit'));
      await tester.pumpAndSettle();

      expect(selectedValue, equals('edit'));
    });

    testWidgets('tooltip is localized', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('fr'),
          home: Scaffold(
            body: Builder(builder: (context) {
              final l10n = AppLocalizations.of(context)!;
              return PopupMenuButton<String>(
                tooltip: l10n.moreOptions,
                itemBuilder: (context) => [],
              );
            }),
          ),
        ),
      );

      // Long press to show tooltip
      await tester.longPress(find.byType(PopupMenuButton<String>));
      await tester.pumpAndSettle();

      expect(find.text('Plus d\'options'), findsOneWidget);
    });
  });
}

Best Practices

  1. Group related actions with dividers and headers
  2. Use consistent icon + text patterns across menus
  3. Provide keyboard shortcuts in menu items where appropriate
  4. Make destructive actions visually distinct (red color)
  5. Include semantic labels for screen reader users
  6. Handle RTL layout for icon and text positioning
  7. Test menu behavior across different locales

Conclusion

PopupMenu localization requires careful attention to action labels, semantic descriptions, and cultural conventions. By implementing proper localization patterns, you ensure users can effectively navigate and use contextual actions regardless of their language. Remember to test with screen readers and various locales to guarantee an accessible and inclusive experience.