fossui
Components

Menu

A dropdown menu, a trigger that opens a panel of rows over the page, each a label with an optional glyph and an optional shortcut, under a highlight that follows the pointer and the arrow keys.

The panel opens below the trigger and flush with its start edge, flips above when there is no room, clamps inside the viewport, and scrolls inside itself when a long list still does not fit. Tapping a row runs it and closes the panel; Escape or a tap outside closes it and returns focus to the trigger.

Menu
import 'package:fossui/fossui.dart';

final controller = FossMenuController();

FossMenu(
  controller: controller,
  items: [
    FossMenuItem(
      label: 'New file',
      shortcut: 'Ctrl N',
      onSelected: createFile,
    ),
    FossMenuItem(
      label: 'Duplicate',
      shortcut: 'Ctrl D',
      onSelected: duplicateFile,
    ),
    FossMenuItem(label: 'Rename', onSelected: renameFile),
  ],
  child: FossButton(
    onPressed: controller.toggle,
    child: const Text('Actions'),
  ),
);

The panel anchors to the box the trigger occupies, not to what it paints inside it. A trigger stretched by its parent, a list child for instance, anchors the panel to the full width, so wrap it in an Align to anchor to the trigger itself.

A trigger that handles its own taps wins the gesture, so the menu never sees it. That covers FossButton with an onPressed, which is the common case: give the menu a FossMenuController and point the button at controller.toggle. A trigger that takes no input of its own, a plain Text or an icon, needs no controller.

Rows

One items list carries every row kind. FossMenuItem is an action, FossMenuItem.separator() a divider, and FossMenuItem.label() a heading, so a caller never passes a second structure to say where a divider sits.

A row takes an icon, a shortcut, destructive for an action that deletes something, and inset to line a row without a glyph up with the rows that have one.

Menu rows with glyphs, a disabled row and a destructive row
FossMenu(
  controller: controller,
  items: [
    FossMenuItem(
      label: 'New file',
      icon: const Icon(LucideIcons.filePlus),
      onSelected: createFile,
    ),
    const FossMenuItem(label: 'Export as PDF'),
    const FossMenuItem(label: 'Move to archive', enabled: false),
    FossMenuItem(
      label: 'Delete',
      icon: const Icon(LucideIcons.trash2),
      destructive: true,
      onSelected: deleteFile,
    ),
  ],
  child: trigger,
);

A row with no onSelected is inert: it stays visible at full strength, keeps its place in the keyboard walk, and does nothing when selected, which is how you write a "coming soon" entry. enabled: false is the other thing, dimming the row and skipping it.

shortcut is shown and announced, never bound. Registering the combination is the app's job, so the menu cannot claim a key the rest of the screen is using.

Groups

A heading names a run of rows and a separator divides two of them. Neither takes input, and the keyboard walk steps over both.

Menu with two groups and a separator
FossMenu(
  controller: controller,
  items: [
    const FossMenuItem.label('File'),
    FossMenuItem(label: 'New file', shortcut: 'Ctrl N', onSelected: create),
    FossMenuItem(label: 'Open', shortcut: 'Ctrl O', onSelected: open),
    const FossMenuItem.separator(),
    const FossMenuItem.label('Share'),
    FossMenuItem(label: 'Copy link', onSelected: copyLink),
  ],
  child: trigger,
);

Placement

side picks the edge the panel prefers and align how it sits across that edge. Both are directional: left and right mirror under a right-to-left reading direction, and so does start and end alignment. The panel flips to the opposite side when the preferred one has no room, and clamps 8 off the viewport edge either way, so neither choice can push it off screen.

FossMenu(
  side: FossMenuSide.top,
  align: FossMenuAlign.end,
  items: items,
  child: trigger,
);

Keyboard and accessibility

ArrowDown and ArrowUp move the highlight and wrap at both ends, skipping disabled rows and keeping inert ones. Enter and Space select the highlighted row. Escape closes the panel and returns focus to the trigger, as does picking a row or tapping outside.

The panel reports as a menu and each action row as a menu item, merged so a row announces its label and its shortcut together. The trigger reports its expanded state. A panel of only separators and headings carries no menu role, since there is nothing in it to be an item.

One-off styling

A FossMenuStyle tweaks a single menu: the panel width floor, fill, border, corners and padding, the row corners, the highlight pair, the row foreground, the glyph size, and the row text style. To move every menu, retheme FossColors instead.

FossMenu(
  items: items,
  style: const FossMenuStyle(minWidth: 220, rowRadius: 2),
  child: trigger,
);

API

FossMenu

PropTypeDefaultDescription
itemsList<FossMenuItem>requiredThe rows, in order: actions, separators, and headings.
childWidgetrequiredThe trigger, rendered as-is and used as the anchor.
controllerFossMenuController?nullImperative open, close, and toggle; needed when the trigger takes its own taps.
sideFossMenuSidebottomThe preferred side to open on.
alignFossMenuAlignstartThe cross-axis alignment to the trigger.
onOpenChangeValueChanged<bool>?nullCalled on every open and close, dismissals included.
styleFossMenuStyle?nullPer-instance overrides on the theme.

FossMenuItem

PropTypeDefaultDescription
labelStringrequiredThe row's text, and the name it announces.
iconWidget?nullA glyph at the start of the row, sized and colored by the menu.
shortcutString?nullThe key combination shown at the row end. Display only.
onSelectedVoidCallback?nullCalled when the row is picked; null leaves the row inert.
enabledbooltrueWhether the row accepts input. False dims it and skips the keyboard walk.
destructiveboolfalseWhether the row reads as a destructive action.
insetboolfalseWhether the row starts past the glyph column.

FossMenuItem.separator() takes nothing. FossMenuItem.label(String, {bool inset}) takes the heading text.

FossMenuController

MemberTypeDescription
isOpenboolWhether the panel is currently open.
open()voidOpens the panel.
close()voidCloses the panel.
toggle()voidOpens it when closed, closes it when open.

FossMenuStyle

FieldTypeDefaultDescription
minWidthdouble?nullSmallest panel width; it grows past this to fit its widest row.
backgroundColorColor?nullPanel fill.
borderColorColor?nullPanel border.
borderRadiusdouble?nullPanel corner radius.
paddingEdgeInsetsGeometry?nullInner padding between the border and the rows.
rowRadiusdouble?nullCorner radius of a row's highlight fill.
highlightColorColor?nullFill behind the highlighted row.
highlightForegroundColor?nullText and glyph color of the highlighted row.
foregroundColor?nullText and glyph color of a row at rest.
iconSizedouble?nullGlyph size in a row's icon slot.
textStyleTextStyle?nullMerged over the resolved row text style.

Live demo

Open the interactive gallery to open a menu, walk it with the arrow keys, and watch what each row reports.

On this page