super_navigation_sidebar 2.3.0
super_navigation_sidebar: ^2.3.0 copied to clipboard
A themeable, responsive app navigation sidebar for Flutter. Three modes — expanded tree with connectors, icon-only rail with hover flyouts, and off-canvas drawer with scrim. Typed NavNode<T> tree, bad [...]
super_navigation_sidebar #
A themeable, responsive app navigation sidebar for Flutter. One data model
renders in three modes the host picks from the available width: a full
expanded labelled tree with │ ├ └ connectors, an icon-only rail with
hover flyouts, and an off-canvas drawer with a scrim. Typed NavNode<T>
tree, badges, shortcut hints, header/footer slots, AppBar integration,
localization, accessibility, RTL. Zero third-party dependencies.
Features #
- 🗂 Three layout modes —
expanded(full labelled tree + connectors),rail(icon-only, hover flyouts),drawer(off-canvas, scrim) — one controller drives all three. - 📐 Auto breakpoints —
NavSidebarBreakpoints().modeFor(width)maps an available width to the right mode: expanded ≥ 1200 px · rail ≥ 768 px · drawer below. - 🧭 AppBar integration —
NavigationSidebarAppBarconnects directly to the controller. Drawer mode inserts a hamburger; desktop mode adds a collapse toggle. Slots:title,pageTitle,globalSearch,middle,actions, custombuilder.NavBreadcrumb<T>andNavigationSidebarSearchFieldare ready-made slot widgets. - 🧱 Integrated
NavigationShell— one widget composes the app bar, pane and content in the Microsoft NavigationView arrangement: full-width spanning header or inset, push or overlay pane behavior, adaptive by width, and correct content margins — no hand-wired Row/Column/Stack. - ⬅️ Back button —
NavigationSidebarAppBar.showBackButton+onBack, enabled fromcontroller.canGoBack(à laIsBackEnabled), in the top-left corner mirroring RTL. - 📌 Footer nav items —
NavSection.placement: NavSectionPlacement.footerpins Settings / Help to the pane bottom, sharing the one selection model (NavigationView'sFooterMenuItems). - 🎯 Fluent selection indicator —
selectionIndicator: NavSelectionIndicator.barswaps the fill for a leading accent pill in the tree and the rail. - 🌳 Typed
NavNode<T>tree — each node carries a strongly-typedvalue(route, screen enum, …);node.valuereads with no casting. - 🎭 Role derived from position — depth-0 leaf = direct; depth-0 branch = module; nested branch = group; nested leaf = item.
- ✅ Active-screen highlight + ancestor auto-expand —
navigate(id)sets the active row, opens every ancestor module, and (in drawer mode) dismisses the drawer. - 🔴 Badges —
NavBadge(text, tone: NavBadgeTone.success/danger/muted). - ⌨ Shortcut hints — two-key
['g', 'd']-style keycaps shown on hover. Visual by default — wrap the shell inNavShortcutBinderto make them working keystrokes. - 🔎 Built-in search & filter —
searchable: trueadds a filter field that matches every level, auto-expands hits, and highlights the matched run. - 🧭 Command palette —
allowSearchDialog: trueadds a search trigger to the pane that opens a fullNavSearchDialogoverlay. The single switch that enables dialog search — noStack/Overlaywiring in the host app. - ⭐ Quick Access favorites —
favoritable: trueadds per-row star toggles and a synthesized favorites band pinned at the top. - 🔢 Screen codes & keyword search —
NavNode.code(SAP-style transaction code, shown as a mono chip in the palette) and hiddenNavNode.keywordsaliases; both matched by the tree filter and the palette. - 🕒 Recent destinations — every successful navigation lands in an MRU
controller.recentslist; the palette opens with a "Recent" band. - 💾 State persistence —
controller.snapshot()/restore()with a JSON-serializableNavSidebarStateSnapshot(active · expanded · favorites · recents · collapsed) — persist per-user across sessions and workstations. - 🔔 Badge roll-up —
aggregateBadges: truesums numeric descendant badges onto a collapsed module row (pending approvals bubble up). - ⌨ Working shortcut chords — wrap the shell in
NavShortcutBinderand everyNavNode.shortcutchord (gthend) actually navigates — suspended while text fields have focus. - 🔒 Permission-gated nodes —
NavNode.locked+lockMessagedim a row, add a lock glyph, block navigation, and surface the reason as a tooltip. - 🟢 Status dots —
NavNode.status(open·closed·locked·attention) marks fiscal-period / ledger state before the label. - ♿ Accessibility — every interactive row is wrapped in
Semantics(button role, selected, expanded/collapsed state, lock/disable hints) and aFocuswithonKeyEventfor keyboard activation (Enter / Space). The drawer close button has an accessible label. - 🌐 Localization —
NavigationSidebarLocalizationsputs every user-facing string in one immutable class. English default; Arabic preset included. - 🔐 Deep immutability —
NavNode.childrenandNavSection.itemsare wrapped inList.unmodifiable()at construction. - 🆔 Duplicate ID validation — debug-build assertion detects duplicate
NavNode.idvalues with a clear error message. - 🛡 Navigation safety —
navigate()returnsbool;onNavigateis only fired when navigation is actually applied (locked/disabled nodes are always refused). - 🌍 RTL — connectors, flyouts, drawer slide direction, and all row
padding mirror under
Directionality(textDirection: TextDirection.rtl, …). - 🔌 Zero dependencies — pure Flutter + Material.
Installation #
dependencies:
super_navigation_sidebar: ^2.1.0
flutter pub get
Setup #
Register NavigationSidebarThemeData on your MaterialApp:
import 'package:super_navigation_sidebar/super_navigation_sidebar.dart';
MaterialApp(
theme: ThemeData(
extensions: const [NavigationSidebarThemeData.light],
),
darkTheme: ThemeData(
extensions: const [NavigationSidebarThemeData.dark],
),
home: const MyApp(),
);
Quick start #
1 · Self-contained (no external controller) #
NavigationSidebar<String>(
sections: [
NavSection(title: 'Overview', items: [
NavNode(id: 'dashboard', label: 'Dashboard',
icon: Icons.dashboard_outlined, value: 'dashboard',
shortcut: ['g', 'd']),
]),
NavSection(title: 'Finance', items: [
NavNode(id: 'accounts', label: 'Accounts',
icon: Icons.menu_book_outlined, children: [
NavNode(id: 'coa', label: 'Chart of Accounts', children: [
NavNode(id: 'accounts_list', label: 'Account List',
icon: Icons.menu_book_outlined, value: 'accounts_list'),
]),
]),
]),
],
active: 'dashboard',
mode: NavSidebarMode.expanded,
onNavigate: (node) => openScreen(node.value!),
);
Note (1.2+):
NavNodeandNavSectionconstructors are no longerconst(children/items are wrapped inList.unmodifiable). Remove theconstkeyword from anyconst NavNode(…)orconst NavSection(…)call sites.
2 · Responsive shell with AppBar #
class _AppShellState extends State<AppShell> {
final _nav = NavigationSidebarController<String>(
sections: mySections,
active: 'dashboard',
);
String _screen = 'dashboard';
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, c) {
final mode = const NavSidebarBreakpoints().modeFor(c.maxWidth);
final sidebar = NavigationSidebar<String>(
controller: _nav,
mode: mode,
header: (ctx, collapsed) => MyLogo(collapsed: collapsed),
onNavigate: (node) => setState(() => _screen = node.value!),
);
if (mode == NavSidebarMode.drawer) {
return Scaffold(
appBar: NavigationSidebarAppBar(
controller: _nav,
mode: NavSidebarMode.drawer,
pageTitle: NavBreadcrumb<String>(controller: _nav),
),
body: Stack(children: [
Positioned.fill(child: PageFor(screen: _screen)),
Positioned.fill(child: sidebar),
]),
);
}
return Row(children: [
sidebar,
Expanded(
child: Column(children: [
NavigationSidebarAppBar(
controller: _nav,
mode: mode,
showCollapseToggle: true,
pageTitle: NavBreadcrumb<String>(controller: _nav),
globalSearch: NavigationSidebarSearchField(controller: _nav),
actions: [NotificationBell(), UserAvatar()],
),
Expanded(child: PageFor(screen: _screen)),
]),
),
]);
});
}
@override
void dispose() { _nav.dispose(); super.dispose(); }
}
Integrated shell (NavigationShell) #
NavigationShell<T> composes the app bar, the navigation pane and the page
content in the Microsoft NavigationView arrangement — so you stop hand-wiring
Row / Column / Stack and getting the alignment subtly wrong. The bar's
leading zone (back button + pane toggle) lines up directly over the pane.
┌────────────────────────────────────┐
│ App bar (full width) │ spanning: back + toggle
├──────────┬────────────────────────┤ sit over the pane
│ Pane │ Content (padded) │
└──────────┴────────────────────────┘
NavigationShell<String>(
controller: _nav,
headerLayout: NavShellHeaderLayout.spanning, // or .inset
paneBehavior: NavPaneBehavior.push, // or .overlay
appBarBuilder: (ctx, mode) => NavigationSidebarAppBar(
controller: _nav,
mode: mode,
showBackButton: true,
onBack: _goBack,
pageTitle: NavBreadcrumb<String>(controller: _nav),
globalSearch: NavigationSidebarSearchField(controller: _nav),
actions: [NotificationBell(), UserAvatar()],
),
sidebarBuilder: (ctx, mode) => NavigationSidebar<String>(
controller: _nav,
mode: mode,
onNavigate: (n) => setState(() => _screen = n.value!),
),
body: PageFor(screen: _screen),
)
| Property | Type | Description |
|---|---|---|
controller |
NavigationSidebarController<T> |
Required. Shared by bar + pane. |
sidebarBuilder |
NavShellSlotBuilder |
Required. Builds the pane for the resolved mode. |
body |
Widget |
Required. Page content. |
appBarBuilder |
NavShellSlotBuilder? |
Builds the app bar; omit for none. |
mode |
NavSidebarMode? |
Force a mode; null = adaptive from width. |
breakpoints |
NavSidebarBreakpoints |
Width thresholds when adaptive. |
headerLayout |
NavShellHeaderLayout |
spanning (default) or inset. |
paneBehavior |
NavPaneBehavior |
push (default) or overlay. |
contentPadding |
EdgeInsetsGeometry? |
Content margins (24 / 12 px default). |
Overlay tip: with
paneBehavior: NavPaneBehavior.overlay, construct the controller withcollapsed: trueso the pane starts closed and opens as a flyout over the content.
Back button #
// Bind can-pop to the controller (à la NavigationView IsBackEnabled):
_nav.canGoBack = router.canPop();
NavigationSidebarAppBar(
controller: _nav, mode: mode,
showBackButton: true,
onBack: () => router.pop(),
)
Footer navigation items #
NavSection(
title: '',
placement: NavSectionPlacement.footer, // pinned to the pane bottom
items: [
NavNode(id: 'help', label: 'Help', icon: Icons.help_outline, value: 'help'),
NavNode(id: 'settings', label: 'Settings',
icon: Icons.settings_outlined, value: 'settings'),
],
)
Fluent selection indicator #
ThemeData(extensions: [
NavigationSidebarThemeData.dark.copyWith(
selectionIndicator: NavSelectionIndicator.bar, // leading accent pill
),
]);
AppBar integration #
NavigationSidebarAppBar #
A PreferredSizeWidget connected to the controller. Rebuilds automatically
when the controller notifies (drawer open, collapsed flag, etc.).
| Property | Type | Description |
|---|---|---|
controller |
NavigationSidebarController |
Required. |
mode |
NavSidebarMode |
Required. Determines leading controls. |
leading |
Widget? |
Custom leading. Null → hamburger in drawer mode. |
title |
Widget? |
App / product name. |
pageTitle |
Widget? |
Breadcrumb or screen subtitle. |
globalSearch |
Widget? |
A NavigationSidebarSearchField or custom widget. |
middle |
Widget? |
Workspace switcher, env badge, etc. |
actions |
List<Widget>? |
Trailing action buttons. |
showCollapseToggle |
bool? |
Collapse ↔ expand toggle (default true in expanded/rail). |
builder |
Function? |
Full custom content — overrides all slots. |
backgroundColor |
Color? |
Falls back to NavigationSidebarThemeData.surface. |
showBorder |
bool |
Bottom hairline border. Default true. |
height |
double? |
Bar height. Default kToolbarHeight (56 px). |
localizations |
NavigationSidebarLocalizations |
Semantic labels for built-in controls. |
NavBreadcrumb<T> #
Reads controller.active and renders a ›-separated ancestor trail:
NavBreadcrumb<String>(
controller: nav,
separator: ' › ', // default
// style / activeStyle for custom TextStyles
)
NavigationSidebarSearchField #
A compact themed search field that drives controller.setQuery in real time —
use it for inline tree filtering (see NavigationSidebar.searchable):
NavigationSidebarSearchField(
controller: nav,
hint: 'Search accounts, journals, reports…',
)
Command-palette search dialog #
Enable the palette with a single switch on the sidebar —
allowSearchDialog: true. The sidebar renders the search trigger inside the
pane (a field in expanded / drawer modes, an icon button in rail) and opens
NavSearchDialog end-to-end — host apps never build the dialog:
NavigationSidebar<String>(
controller: nav,
allowSearchDialog: true, // ← the only switch that enables search
searchHint: 'Search tabs & actions…',
onSearchPick: (node) { … }, // optional — navigates + fires onNavigate by default
)
For custom entry points (a button, a keyboard shortcut) the same dialog can be
opened imperatively from any BuildContext:
showNavSearchDialog<String>(context, controller: nav);
Or embed in a Stack directly:
Stack(children: [
MyShell(),
if (_open)
NavSearchDialog<String>(
controller: _nav,
onClose: () => setState(() => _open = false),
),
])
NavSearchOps — low-level helpers for custom search UIs:
final index = NavSearchOps.buildIndex<String>(_nav.sections); // List<NavSearchHit>
final hits = NavSearchOps.filter(index, 'journal entry');
Localization #
All user-facing strings are in NavigationSidebarLocalizations. Pass a
custom instance via NavigationSidebar.localizations and
NavigationSidebarAppBar.localizations:
// English (default — no configuration needed):
NavigationSidebar<String>(controller: nav, mode: mode)
// Arabic (pair with RTL Directionality):
Directionality(
textDirection: TextDirection.rtl,
child: NavigationSidebar<String>(
controller: nav,
mode: mode,
localizations: NavigationSidebarLocalizations.arabic,
),
)
// Partial override — only change what you need:
NavigationSidebar<String>(
controller: nav, mode: mode,
localizations: const NavigationSidebarLocalizations(
searchHint: 'Buscar navegación…',
lockedDefault: 'Acceso restringido',
),
)
Available strings #
| Field | Default (English) |
|---|---|
searchHint |
'Search navigation…' |
searchEmpty |
'No matches for "{query}"' |
drawerTitle |
'Navigation' |
drawerCloseLabel |
'Close navigation' |
quickAccessTitle |
'Quick Access' |
recentsTitle |
'Recent' |
addToQuickAccess |
'Add to Quick Access' |
removeFromQuickAccess |
'Remove from Quick Access' |
lockedDefault |
"Locked — you don't have access" |
shortcutPrefix |
'Shortcut · ' |
shortcutSeparator |
' then ' |
semanticExpanded |
'expanded' |
semanticCollapsed |
'collapsed' |
semanticLocked |
'locked' |
semanticDisabled |
'disabled' |
semanticToggleSidebar |
'Toggle sidebar' |
semanticOpenDrawer |
'Open navigation' |
Data model #
NavSection<T> #
NavSection<String>(
title: 'Finance',
items: [...], // List<NavNode<T>> — unmodifiable after construction
)
NavNode<T> #
| Field | Type | Description |
|---|---|---|
id |
NavNodeId (String) |
Required. Unique across the whole sidebar (validated in debug builds). |
label |
String |
Required. Display text; search matches against this. |
code |
String? |
Short screen code ('JE01') — mono chip in the palette, searchable. |
keywords |
List<String>? |
Hidden search aliases — matched, never rendered. |
icon |
IconData? |
Leading icon. |
children |
List<NavNode<T>> |
Child nodes. Unmodifiable after construction. |
value |
T? |
Strongly-typed host payload. |
badge |
NavBadge? |
Trailing badge pill. |
shortcut |
List<String>? |
Visual hint only — ['g', 'd'] renders G › D. |
locked |
bool |
Permission-gate: dim, lock glyph, block nav, tooltip. |
lockMessage |
String? |
Tooltip on a locked row. |
status |
NavNodeStatus |
State dot — none/open/closed/locked/attention. |
enabled |
bool |
When false, row shown but not activatable. |
Navigation safety #
navigate() returns bool: true = applied, false = refused (locked,
disabled, or id not found). onNavigate is only fired on true:
// Safe — onNavigate is never called for locked / disabled nodes:
NavigationSidebar<String>(
controller: nav,
mode: mode,
onNavigate: (node) => setState(() => _screen = node.value!),
)
// Programmatic nav with return-value check:
final ok = nav.navigate('wire'); // false if locked/disabled
Deep immutability #
NavNode.children and NavSection.items are List.unmodifiable after
construction. Attempts to mutate throw UnsupportedError:
final node = NavNode(id: 'parent', label: 'Parent', children: [
NavNode(id: 'child', label: 'Child'),
]);
node.children.add(NavNode(id: 'x', label: 'X')); // throws UnsupportedError
All structural changes must go through the controller (replaceSections,
navigate, etc.) which notifies listeners correctly.
Duplicate ID validation #
In debug builds the controller asserts that every NavNode.id is unique:
NavigationSidebarController<String>(sections: myBadSections);
// → AssertionError: duplicate NavNode IDs detected: [myId]
Programmatic check (for tests or host validation):
final dups = NavOps.findDuplicateIds<String>(sections);
assert(dups.isEmpty, 'Duplicate nav ids: $dups');
Accessibility #
- Every interactive row:
Semantics(button role,selected,toggledexpanded/collapsed, lock/disable hints) +Focus+onKeyEvent(Enter/Space). - Drawer close button: accessible label from
localizations.drawerCloseLabel. - Locked/disabled rows:
SystemMouseCursors.forbiddencursor. - Rail items:
Tooltipwith the node label; lock glyph shown on locked nodes.
ERP / banking features #
Built-in search & filter #
NavigationSidebar<String>(
controller: nav,
searchable: true,
searchHint: 'Search accounts, journals, reports…',
);
Command palette (search dialog) #
NavigationSidebar<String>(
controller: nav,
allowSearchDialog: true, // the single switch that enables dialog search
searchHint: 'Search tabs & actions…',
);
Quick Access (favorites) #
NavigationSidebarController<String>(
sections: sections,
favorites: {'journalEntry', 'trialBalance'},
);
NavigationSidebar<String>(controller: nav, favoritable: true);
Screen codes & keyword search (2.2) #
NavNode(id: 'journalEntry', label: 'Journal Entry', value: 'journalEntry',
code: 'JE01', // mono chip + searchable
keywords: ['قيد', 'voucher', 'GL entry']); // hidden aliases
Recent destinations (2.2) #
nav.recents; // MRU ids — filled automatically on navigate()
nav.recentNodes; // resolved nodes
// The command palette shows a "Recent" band while the query is empty.
State persistence (2.2) #
// Persist on change:
nav.addListener(() =>
prefs.setString('nav', jsonEncode(nav.snapshot().toJson())));
// Restore on launch:
nav.restore(NavSidebarStateSnapshot.fromJson(jsonDecode(raw)));
Badge roll-up (2.2) #
NavigationSidebar<String>(controller: nav, aggregateBadges: true);
// Closed "Finance" module shows “12” — the sum of its descendants' counts.
Working shortcut chords (2.2) #
NavShortcutBinder<String>(
controller: nav,
onNavigate: (n) => openScreen(n.value!),
child: NavigationShell<String>(…),
);
Permission-gated nodes #
NavNode(id: 'wire', label: 'Wire / SWIFT', icon: Icons.bolt_outlined,
value: 'wire', locked: true,
lockMessage: 'Requires Treasury Approver role');
Status dots #
NavNode(id: 'fy25q3', label: 'FY2025 · Q3', value: 'fy25q3',
status: NavNodeStatus.open); // open · closed · locked · attention
Three modes #
expanded — 248 px full tree #
rail — 76 px icon column with hover flyouts #
drawer — 280 px off-canvas overlay #
// Place in a Stack + Positioned.fill; open via controller.openDrawer():
Stack(children: [
Positioned.fill(child: MyPage()),
Positioned.fill(
child: NavigationSidebar<String>(
controller: nav,
mode: NavSidebarMode.drawer,
),
),
])
NavigationSidebarController<T> API #
Navigation #
| Method | Returns | Description |
|---|---|---|
navigate(id) |
bool |
Set active, open ancestors, close drawer. false if refused. |
Expansion #
| Method | Description |
|---|---|
expand(id) · collapse(id) · toggleNode(id) |
Open / close / flip a branch. |
expandAll() · collapseAll() |
All branches. |
Rail / drawer #
| Method / setter | Description |
|---|---|
toggleCollapsed() · collapsed = bool |
Flip expanded ↔ rail. |
openDrawer() · closeDrawer() · toggleDrawer() |
Drawer state. |
Data #
| Method | Description |
|---|---|
replaceSections(sections) |
Hot-swap the section forest. Validates duplicates in debug. |
setQuery(q) · matchSet() |
Search filter. |
toggleFavorite(id) · setFavorites(ids) |
Quick Access. |
recents · recentNodes · clearRecents() |
MRU history (auto-filled by navigate). |
snapshot() · restore(s) |
Persistable NavSidebarStateSnapshot (JSON). |
Reads #
| Property / method | Description |
|---|---|
sections |
List<NavSection<T>> |
active / activeValue |
Active id / typed value. |
collapsed / drawerOpen / filtering |
State flags. |
isActive(id) / isExpanded(id) / ownsActive(id) |
Per-node queries. |
node(id) |
NavNode<T>? by id. |
matchSet() |
Matching ids + ancestors for the current query. |
favorites / favoriteNodes / isFavorite(id) |
Quick Access. |
Theming #
ThemeData(
extensions: [
NavigationSidebarThemeData.light.copyWith(
surface: const Color(0xFFFFFFFF),
bg: const Color(0xFFF5F3EF),
border: const Color(0xFFDDD7CE),
),
],
)
Architecture #
lib/
├── super_navigation_sidebar.dart public barrel
└── src/
├── models.dart NavSection · NavNode · NavBadge · NavBadgeTone
│ NavNodeRole · NavNodeId · NavNodeStatus
│ NavSidebarMode · NavSidebarBreakpoints · NavOps
├── theme.dart NavigationSidebarThemeData (ThemeExtension)
├── localizations.dart NavigationSidebarLocalizations
├── controller.dart NavigationSidebarController (ChangeNotifier)
│ NavigationSidebarScope (InheritedNotifier)
├── sidebar.dart NavigationSidebar<T> widget
│ _NavRow · _RailItem · _RailFlyout · _FlyoutRow
│ _NavBadgeChip · _ShortcutHint · _StarButton
├── appbar.dart NavigationSidebarAppBar · NavBreadcrumb<T>
│ NavigationSidebarSearchField
├── search_dialog.dart NavSearchDialog<T> · NavSearchHit · NavSearchOps
│ showNavSearchDialog
└── shell.dart NavigationShell<T> · NavShellSlotBuilder
MVC: immutable NavNode<T> models → NavigationSidebarController<T>
(ChangeNotifier) → thin NavigationSidebar<T> view →
NavigationSidebarThemeData (ThemeExtension). The controller is published to
descendant pages via NavigationSidebarScope<T> (InheritedNotifier).
Gotchas #
- Host derives
mode. UseLayoutBuilder+NavSidebarBreakpoints().modeFor(width). - Role is positional. Depth determines visual treatment, not an explicit field.
valuevsid.valueis the typed host payload;idis the nav identity.- Drawer must overlay. Place in
Stack + Positioned.fill. - Register the theme extension. Without it the dark preset is used.
- Shortcuts need a binder. Keycaps are visual until you wrap the shell in
NavShortcutBinder(or wireShortcuts/Actionsyourself). - No
const NavNode/NavSection(1.2+). Constructors are non-const; remove theconstkeyword. navigate()returnsbool. Code calling it in a void context compiles unchanged; only explicitvoidvariable assignment needs updating.NavigationSidebar(allowSearchDialog: true)— the single switch that enables the search dialog;showNavSearchDialogis the imperative escape hatch.
Additional information #
- Changelog: CHANGELOG.md
- Repository: https://github.com/GeniusSystems24/super_navigation_sidebar
- Issues: https://github.com/GeniusSystems24/super_navigation_sidebar/issues
- License: MIT — see LICENSE