GeniusLink Design System
A themeable, MVC Flutter widget kit ported from the GeniusLink web design system. Five production-grade, self-contained components β a browser-style tab bar, an Excel-style editable table with nine typed column kinds, a read-only readable table with selection + sort, a customisable tree, and a responsive app navigation sidebar β all theme-aware (light + dark) and bilingual (LTR + RTL).
Both tables now ship resizable + reorderable columns, typed ReadableTable column kinds, and TSV clipboard copy (rows / cells paste straight into a spreadsheet); the Tree adds a single / multi-select layer with add & remove; and BrowserStyleTabBar preserves each tab's page state across switches. Keyboard arrows resolve to the visual direction in RTL, and keyboard focus scrolls into view in the table + tree.
πΊ Interactive docs: open
docs/index.htmlin a browser for a live, per-component reference β each page mirrors the Flutter widget and runs in light / dark and LTR / RTL.
Features
- π§© Five components, one kit β
BrowserStyleTabBar,EditableTable,ReadableTable,Tree,NavigationSidebar. - π¨ Self-contained theming β each component carries its own
ThemeExtensionwith ready-made.light/.darkpresets. No global token file required. - ποΈ Strict MVC β immutable models, a
ChangeNotifiercontroller as the single source of truth, and a thin view. Drive any component from outside, or from its own page content via anInheritedNotifierscope. - β¨οΈ Full keyboard control β spreadsheet navigation, inline editing, copy/cut/paste, undo/redo, and an in-widget shortcuts reference.
- π Resizable + reorderable columns β drag a header edge to resize (RTL-mirrored, double-tap to reset), drag a header to reorder; both tables.
- π TSV clipboard copy β a row, many rows, a cell or a cell rectangle serialize to tab-separated values and paste into Sheets / Excel / Numbers.
- β Tree single / multi-select β Shift-range, Ctrl/β-toggle, tri-state checkboxes; group add / remove.
- β»οΈ State-preserving tabs β every tab page is built once and kept alive, so scroll / input / controllers survive switching.
- π RTL + dark everywhere β mirrors via
Directionality+EdgeInsetsDirectional, and arrow keys follow the visual direction. - π Zero third-party dependencies β pure Flutter + Material.
Install
dependencies:
geniuslink_design_system:
git: # or a path/hosted source
url: https://example.com/geniuslink_design_system.git
import 'package:geniuslink_design_system/geniuslink_design_system.dart';
Prefer a leaner import? Each component ships its own barrel:
import 'package:geniuslink_design_system/geniuslink_browser_tabs.dart';
import 'package:geniuslink_design_system/geniuslink_editable_table.dart';
import 'package:geniuslink_design_system/geniuslink_readable_table.dart';
import 'package:geniuslink_design_system/geniuslink_tree.dart';
import 'package:geniuslink_design_system/geniuslink_navigation_sidebar.dart';
Register the theme extensions you use (each falls back to its .dark preset if absent):
MaterialApp(
theme: ThemeData(extensions: const [
BrowserStyleTabBarThemeData.light,
EditableTableThemeData.light, // also styles ReadableTable
TreeThemeData.light,
NavigationSidebarThemeData.light,
]),
darkTheme: ThemeData(extensions: const [
BrowserStyleTabBarThemeData.dark,
EditableTableThemeData.dark, // also styles ReadableTable
TreeThemeData.dark,
NavigationSidebarThemeData.dark,
]),
);
Run the example
The package is a library (no lib/main.dart). Run the example app:
cd geniuslink_design_system_flutter/example
flutter pub get
flutter run -d chrome # or any device / emulator
It opens on a launcher of demos, each hosting the components in a realistic shell β an all-in-one ERP Console (tree sidebar + tabs + tables), an EditableTable gallery (every column type), a ReadableTable gallery (every selection mode + sort), a Tree gallery, plus Figma- and Chrome-style tab-bar shells.
Components
1 Β· EditableTable
An Excel-style data-entry grid. Click to select, type to overwrite, Enter β / Tab β to move, sort by clicking a header, undo/redo β with nine typed column kinds, row-aware validation, optional delete confirmation, and a totals footer.
Quick start
EditableTable(
columns: [
EditableColumn(key: 'name', label: 'Account', required: true),
NumericColumn(key: 'balance', label: 'Balance', includeInTotal: true),
],
initialRows: const [
{'name': 'Cash', 'balance': '42,500.00'},
{'name': 'Bank', 'balance': '186,420.00'},
],
showTotals: true,
unitLabel: 'SAR',
onChanged: (rows) => debugPrint('${rows.length} rows'),
);
EditableRow is just Map<String, String> β values are the strings the user typed; you parse on read. Provide columns + initialRows (the widget owns a controller), or pass a controller: to drive/observe it externally.
Column types
Each kind is an ergonomic subclass of EditableColumn β pass the right one; the table picks the editor automatically.
| Column | Editor | Stores |
|---|---|---|
EditableColumn |
inline text | free text |
NumericColumn |
inline numeric (min/max/decimals) | grouped number 1,234.00 |
DateColumn |
masked YYYY-MM-DD + π
calendar button |
ISO date |
TimeColumn |
masked HH:mm + π clock button |
24h time |
ComboBoxColumn |
free text + suggestions βΎ | any string |
DropdownColumn |
popup menu (strict) | one of options |
ColorPickerColumn |
swatch menu | #RRGGBB hex |
ReadonlyColumn |
β (never editable) | display only |
ComputedColumn |
β (derived from the row) | compute(row) |
final columns = <EditableColumn>[
const ReadonlyColumn(key: 'id', label: 'ID', mono: true),
const EditableColumn(key: 'task', label: 'Task', required: true),
// Numeric β clamped, integer:
const NumericColumn(key: 'qty', label: 'Qty', min: 0, decimals: 0),
const NumericColumn(key: 'price', label: 'Price', min: 0, decimals: 2, includeInTotal: true),
// Computed β recomputes on every edit:
ComputedColumn(
key: 'total', label: 'Total', includeInTotal: true,
compute: (r) {
final q = EditableTableFormat.parseNumber(r['qty'] ?? '') ?? 0;
final p = EditableTableFormat.parseNumber(r['price'] ?? '') ?? 0;
return EditableTableFormat.formatNumber(q * p);
},
),
// Web-style date & time fields β type with the keyboard, or use the picker button:
const DateColumn(key: 'due', label: 'Due date'),
const TimeColumn(key: 'at', label: 'Time'),
// Strict dropdown vs. free-text combo:
const DropdownColumn(key: 'status', label: 'Status', options: ['Open', 'Active', 'Done']),
const ComboBoxColumn(key: 'tag', label: 'Tag', options: ['Design', 'Build', 'QA']),
// Colour β cell shows a swatch + hex, edits via a swatch menu:
const ColorPickerColumn(key: 'color', label: 'Colour'),
];
Validation
Two hooks, both fed into the red cell border and the toolbar's validity badge:
// value-only
NumericColumn(
key: 'bal', label: 'Balance',
validate: (v) => (EditableTableFormat.parseNumber(v) ?? 0) < 0 ? 'No negatives' : null,
);
// row-aware (cross-column) β receives the whole row
EditableColumn(
key: 'total', label: 'Line Total',
cellValidator: (value, row) {
final q = EditableTableFormat.parseNumber(row['qty'] ?? '');
final p = EditableTableFormat.parseNumber(row['price'] ?? '');
final t = EditableTableFormat.parseNumber(value);
if (q == null || p == null || t == null) return null;
return (q * p - t).abs() > 0.01 ? 'β Qty Γ Price' : null;
},
);
Custom cell rendering
cellBuilder replaces a cell's read-only content with any widget (a chip, badge, progress barβ¦). The cell stays selectable/editable; you get the value, the whole row, selection/invalid state, and a requestEdit callback:
DropdownColumn(
key: 'status', label: 'Status', options: ['Open', 'Active', 'Done'],
cellBuilder: (context, cell) => GestureDetector(
onTap: cell.requestEdit,
child: Chip(label: Text(cell.value)),
),
);
Keyboard shortcuts
Press the β¨ button in the toolbar (or β/Ctrl + /) for the in-widget cheatsheet.
β β β β |
Move between cells |
Tab / β§Tab |
Next / previous cell β Tab past the last cell appends a row (growOnTab) |
Home / End Β· β+Home/End |
First / last column Β· first / last cell |
Type Β· Enter / F2 |
Overwrite Β· edit (or open a select) |
Enter β Β· Tab β |
Commit & move |
β+Enter Β· β+D Β· β+β« |
Add row Β· duplicate row Β· delete row |
β+C / X / V Β· β+Z / β§Z |
Copy / cut / paste cell Β· undo / redo |
Arrow keys resolve to the visual direction β in an RTL (Directionality.rtl) layout the right arrow moves to the cell on the right (the previous column). Navigating to an off-screen cell scrolls it fully into view (both axes) via Scrollable.ensureVisible.
Column resize & reorder
Both EditableTable and ReadableTable carry the same column-layout API on their controller, and the header is the UI for it:
- Resize β drag a header cell's trailing (inline-end) edge; the grid reflows live. Width is clamped to
columnMinWidth β¦ columnMaxWidth(64 β¦ 520 px) and double-tapping the handle resets the column. The drag delta is RTL-mirrored (the handle sits on the visual left in RTL). - Reorder β long-press a header and drag it onto another; a blue indicator marks the drop, and the whole grid (header, body, footer) rearranges at once. Only the visual order changes β sort, selection and cell values stay keyed by each column's stable logical index.
final c = EditableTableController(columns: cols, rows: seed);
c.resizeColumn(2, 40); // widen the 3rd visual column by 40px
c.resetColumnWidth(2); // back to its declared width
c.moveColumn(4, 1); // drag column 5 β position 2
c.widthOf(0); // effective width of the 1st visual column
c.columnOrder; // [logicalβ¦] in current visual order
Copy to the clipboard (TSV)
Beyond the single-cell βC/βX/βV, the controller serializes whole rows or a cell rectangle to tab-separated values so the result pastes straight into a spreadsheet. Tabs/newlines inside a value are flattened so one cell can't spill into its neighbours.
await c.copyRowsToClipboard([0, 2, 3], includeHeader: true); // 3 rows β TSV
await c.copyCellsToClipboard([CellRef(0,1), CellRef(0,2), CellRef(1,1)]);
final tsv = c.rowsAsTsv([0, 1]); // serialize without touching the clipboard
Generic rows β EditableTable<T>
A typed-row variant (EditableTable<T> / EditableColumn<T>) lets each row be a strongly-typed immutable value instead of a Map<String,String> β each row is a List<T> of your own model and every column carries value: (T) => String plus setValue: (T, raw) => T accessors (mirroring ReadableTable<T>; a null setValue marks a read-only / computed column). It ships as its own barrel β import it instead of the map-backed table, since both declare the same names:
import 'package:geniuslink_design_system/geniuslink_editable_table_generic.dart';
@immutable
class InvoiceRow { final String item; final int qty; final double price; /* +copyWith */ }
final c = EditableTableController<InvoiceRow>(
columns: [
EditableColumn(label: 'Item', value: (r) => r.item, setValue: (r, v) => r.copyWith(item: v)),
NumericColumn (label: 'Qty', value: (r) => '${r.qty}', setValue: (r, v) => r.copyWith(qty: int.tryParse(v) ?? r.qty), decimals: 0),
DropdownColumn(label: 'Unit', options: ['ea','box'], value: (r) => r.unit, setValue: (r, v) => r.copyWith(unit: v)),
DateColumn (label: 'Due', value: (r) => iso(r.due), setValue: (r, v) => r.copyWith(due: parse(v))),
CheckboxColumn(label: 'Paid', value: (r) => r.paid ? '1' : '0', setValue: (r, v) => r.copyWith(paid: v == '1')),
ComputedColumn(label: 'Total', compute: (r) => money(r.qty * r.price), includeInTotal: true), // read-only
],
rows: seed,
newRow: () => InvoiceRow.blank(), // enables Add-row + Tab-to-grow
);
EditableTable<InvoiceRow>(controller: c); // inline edit Β· sort Β· resize Β· reorder Β· copy Β· keyboard
Typed column constructors β NumericColumn (clamp + decimals), DropdownColumn (strict options), DateColumn, CheckboxColumn, ComputedColumn (read-only, derived) β declare a column's kind and editing affordance. The legacy map table is simply T = EditableRow via mapColumn('key', 'Label'). See example/lib/editable_table_demo.dart for a full EditableTable<InvoiceRow> with all kinds, resize / reorder, TSV copy and an RTL toggle.
Selection layer. Beyond the editing cursor, EditableTableController<T> carries the same five selection modes as ReadableTable β EditableSelectionMode.{none, singleRow, multiRow, singleCell, multiCell}. Click selects; Shift-click extends a range / rectangle, β/Ctrl-click toggles, β/Ctrl+A selects all, and β/Ctrl+C copies the selection (rows or a cell rectangle) as TSV.
final c = EditableTableController<InvoiceRow>(columns: cols, rows: seed,
selectionMode: EditableSelectionMode.multiRow);
c.setSelectionMode(EditableSelectionMode.multiCell); // flip at runtime
c.selectRow(2, range: true); c.selectCell(0, 1, additive: true);
c.selectAll(); c.clearSelection(); c.deleteSelectedRows();
c.selectedRows; c.selectedCells; c.selectedCount; // reads
await c.copySelectionTsvToClipboard(includeHeader: true);
Options
EditableTable(
columns: columns,
showToolbar: true, // validity badge, clipboard hint, shortcuts, undo/redo
showRowNumbers: true, // A1-style gutter
showActions: true, // per-row insert-below / delete
showTotals: true, // footer summing includeInTotal columns
totalsLabel: 'Total',
unitLabel: 'SAR',
confirmDelete: true, // popup before deleting (set false = instant)
growOnTab: true, // Tab on the last cell adds a new row
showShortcutsHelp: true, // the β¨ reference button
);
Driving it from code β EditableTableController
final c = EditableTableController(columns: columns, rows: seed);
c.addRow(); // append a blank row
c.insertRowAt(2); // blank row at index 2
c.duplicateSelectedRow();
c.deleteRowAt(3);
c.sortByColumn(1); // cycles asc β desc
c.setRows(loadedRows); // replace all rows (one undo step)
c.undo(); c.redo(); // c.canUndo / c.canRedo
final rows = c.rows; // List<EditableRow> β the current data
EditableTable(controller: c); // observe / share it
// from inside a custom cell / page:
EditableTableController.of(context)?.addRow();
2 Β· ReadableTable
A read-only, generic, MVC display grid that shares the EditableTable look (it reuses EditableTableThemeData β identical header, hairline grid, surfaces, type ramp). Where EditableTable edits strings, ReadableTable<T> displays strongly-typed row values β and adds the read-only interaction layer a display grid needs: selection, keyboard navigation and click-to-sort. It's generic over the row value type T: each row is one T, and every ReadableColumn<T> renders itself from that value via cell, so row code reads value.field with no casting.
Quick start
ReadableTable<Account>(
selectionMode: ReadableSelectionMode.multiRow,
columns: [
ReadableColumn('Code', width: 90, sortable: true,
sortKey: (a) => a.code, cell: (ctx, a) => Text(a.code)),
ReadableColumn('Account', flex: 2, sortable: true,
sortKey: (a) => a.name, cell: (ctx, a) => Text(a.name)),
ReadableColumn('Balance', align: ReadableAlign.end, sortable: true,
sortKey: (a) => a.balance, cell: (ctx, a) => Text(a.fmt)),
],
rows: accounts, // List<Account>
onRowSelectionChanged: (rows) => debugPrint('$rows'), // List<Account>
);
Columns size by width: (fixed px) or flex: (proportional, filling the row). Cells are arbitrary widgets β status pills, two-line bilingual text, progress bars β placed and aligned for you, no horizontal scroll. Provide columns + rows (the widget owns a controller), or pass a controller: to drive/observe it externally.
Just a grid of pre-built widgets? Use
ReadableTable<List<Widget>>and have each column returnvalue[i]. That's exactly how the desktopGLTablewrapper keeps itsList<List<Widget>>API.
Typed column kinds
Beyond the unnamed ReadableColumn(cell: β¦) constructor, named factories declare a column's intent and get consistent formatting, alignment and a typed sort key for free β the same diversity of kinds as EditableTable, read-only:
ReadableColumn.text('Account', value: (a) => a.name, secondary: (a) => a.nameAr), // optional 2nd line / bilingual
ReadableColumn.number('Balance', value: (a) => a.balance, decimals: 2, colorSign: true),
ReadableColumn.enumBadge('Type', value: (a) => a.type, color: typeColor), // coloured pill
ReadableColumn.date('Opened', value: (a) => a.opened),
ReadableColumn.time('At', value: (a) => a.time),
ReadableColumn.color('Tag', hex: (a) => a.hex), // swatch + hex
ReadableColumn.progress('Used', value: (a) => a.ratio), // labelled bar
ReadableColumn.link('Doc', text: (a) => a.ref, onTap: (a) => open(a)),
The renderers live in readable_table_cells.dart (ReadableCells.*) β theme-driven and intl-free. The original custom-cell constructor keeps working unchanged.
Selection β five modes
Set selectionMode: to one of ReadableSelectionMode.{none, singleRow, multiRow, singleCell, multiCell} (default none = display only). Pointer: click selects; Ctrl/β-click toggles; Shift-click extends a range (linear for rows, rectangular for cells). onRowSelectionChanged reports the selected values (List<T>); onCellSelectionChanged gives a Set<ReadableCell>.
Column sort
Mark a column sortable: true and click its header to cycle asc β desc (an arrow marks the active column). Supply sortKey: (value) => Comparable per column β numbers sort numerically, strings alphabetically. Sorting reorders the rows and remaps the selection / cursor so they follow their rows. initialSortColumn / initialSortAscending / onSortChanged round it out.
Keyboard
Focus the table (click it), then β press ? (or β/Ctrl + /) for the in-widget cheatsheet:
β β Β· β β |
Move active row Β· move active cell (cell modes) |
Space |
Toggle (multi) / select the active row or cell |
β§ + ββββ |
Extend the selection (multi modes) |
β/Ctrl + A |
Select all rows / cells |
Enter |
Activate the active row (onRowTap β value + index) |
Home / End Β· β+Home/End |
Row edges Β· grid corners |
Esc |
Clear the selection |
Driving it β ReadableTableController<T>
The controller is the single source of truth; the view is a thin render of it. It exposes intention-revealing operations β select / add / delete / replace rows by index Β· value Β· where Β· firstWhere β and is published to descendants via a scope (ReadableTableController.of<T>(context)).
final c = ReadableTableController<Account>(
columns: columns, rows: accounts,
selectionMode: ReadableSelectionMode.multiRow,
);
ReadableTable<Account>(controller: c); // observe / share it
| Group | Operations |
|---|---|
| Select rows | selectRowAt(index, {additive, range}) Β· selectRowByValue(value, {additive}) Β· selectRowsWhere(test, {additive}) Β· selectAllRows() Β· clearSelection() |
| Add rows | insertRowAt(index, value) Β· addRowWhere(test, value, {after, firstOnly}) Β· addRow(value) (end) |
| Delete rows | deleteRowAt(index) Β· deleteRowsWhere(test) Β· deleteRowByValue(value) Β· deleteSelectedRows() |
| Replace row | replaceRowAt(index, value) Β· replaceRowByValue(old, new) Β· replaceRowsWhere(test, update) Β· replaceFirstWhere(test, update) |
| Cells / sort / data | selectCellAt(r, c, β¦) Β· selectAllCells() Β· sortByColumn(ci) Β· clearSort() Β· setRows(values) |
c.selectRowsWhere((a) => a.type == 'Expense'); // select β¦ where
c.addRowWhere((a) => a.type == 'Asset', newAccount); // add β¦ where (after match)
c.deleteRowByValue(oldAccount); // delete β¦ by value
c.replaceFirstWhere((a) => a.type == 'Asset', // replace β¦ firstWhere
(a) => a.copyWith(balance: a.balance * 1.1));
// from inside a custom cell / page:
ReadableTableController.of<Account>(context)?.deleteSelectedRows();
Column resize & reorder Β· copy (TSV)
The controller carries the same column-layout API as EditableTable β drag a header edge to resize (RTL-mirrored, double-tap to reset, clamped 64β¦520 px) and long-press a header to reorder it (a blue indicator marks the drop). Only the visual order changes; selection, sort and cell addresses stay keyed by logical index.
c.resizeColumn(1, 30); c.resetColumnWidth(1);
c.moveColumn(3, 0); c.columnOrder; c.widthOf(0);
The current selection β a row, many rows, a cell or a cell rectangle β copies to the system clipboard as tab-separated values (wired to β/Ctrl + C). Unselected interior cells in a rectangle are emitted blank so the block keeps its shape; per-column copyText: supplies the flat string a widget cell can't.
await c.copySelectionToClipboard(includeHeader: true); // β Sheets / Excel
final tsv = c.copySelectionAsTsv(); // without the clipboard
Options
ReadableTable<T>(
controller: c, // or columns + rows
selectionMode: ReadableSelectionMode.none,
showHeader: true,
zebra: false, // faint fill on odd rows
hoverHighlight: true, // tint the row under the pointer
rowMinHeight: 52,
cellPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
onRowTap: (value, index) {},
emptyState: const Text('No rows'),
);
Defaults reproduce a plain, non-interactive ledger (selectionMode: none, no keyboard) β so adopting ReadableTable for display-only tables changes nothing until you opt into a mode.
3 Β· Tree
A customisable generic hierarchical tree / outline β file explorers, category outlines, layer panels, a chart of accounts. Every node is TreeNode<T> carrying a strongly-typed value, so row code reads node.value with no casting. Indent guide-lines, disclosure twisties, click / keyboard selection (single or multi), inline rename, search-with-highlight, tri-state checkboxes, a context menu, and undo/redo.
Quick start
// Untyped (T = dynamic) β the simplest form:
Tree(
roots: const [
TreeNode(id: 'src', label: 'src', folder: true, children: [
TreeNode(id: 'main', label: 'main.dart'),
TreeNode(id: 'ui', label: 'ui', folder: true, children: [
TreeNode(id: 'button', label: 'button.dart', badge: 'edited'),
]),
]),
TreeNode(id: 'readme', label: 'README.md'),
],
initiallyExpanded: const {'src', 'ui'},
onSelected: (node) => debugPrint('opened ${node.id}'),
);
Provide roots + initiallyExpanded (the widget owns a controller), or pass a controller: to drive / observe it externally (and to opt into multi-select β see below).
The node β TreeNode<T>
Immutable schema; a host composes a List<TreeNode<T>>, each with its own children, to describe the whole tree:
| Field | Type | Meaning |
|---|---|---|
id |
String |
Required. Stable, unique identity (path / uuid / db id). |
label |
String |
Required. Display text β also what rename and search match. |
children |
List<TreeNode<T>> |
Child nodes. Empty for a leaf. |
value |
T? |
Strongly-typed host payload (node.value is a T, no cast). |
icon |
IconData? |
Leading-icon override (else inferred: folder / leaf). |
badge |
String? |
Trailing badge text (a count, a statusβ¦). |
folder |
bool? |
Force folder/leaf. null β folder iff it currently has children. |
selectable |
bool |
When false the row can't be selected (still shown / expandable). |
data |
Map<String, Object?> |
Incidental metadata; prefer value for the payload. |
Typed nodes β Tree<T>
// Each node carries a typed Account; node.value is an Account, no casts.
final roots = <TreeNode<Account>>[
TreeNode(id: '1000', label: 'Assets',
value: Account(code: '1000', type: 'Asset'),
children: [ /* β¦ */ ]),
];
Tree<Account>(
roots: roots,
trailingBuilder: (ctx, row) => Text(row.node.value!.nature), // DR / CR
onActivated: (n) => openLedger(n.value!),
);
See example/lib/tree_demo.dart + account_tree_data.dart for a full typed
chart-of-accounts, and docs/components-tree.html for an interactive gallery
with three value types (Account, FileMeta, Person).
Selection β single or multi
A click / keyboard selection layer sits on the controller, independent of the checkbox layer. Set TreeController.selectionMode to one of TreeSelectionMode.{none, single, multi} (default single) β it's a mutable field, so a host can flip into a "select mode" at runtime. Because the mode lives on the controller, multi-select means passing a controller::
final t = TreeController<Account>(
roots: roots,
selectionMode: TreeSelectionMode.multi,
);
Tree<Account>(controller: t, onSelected: (n) {});
Pointer / keyboard: a plain click resets to one node; Ctrl/β-click toggles a node; Shift-click (and Shift + β/β) selects the contiguous visible range from the anchor; β/Ctrl + A selects every visible node. Read the result for group actions:
t.selection; // Set<TreeNodeId> β every selected id
t.selectedNodes; // List<TreeNode<T>> in visible (top-to-bottom) order
t.selectionCount; // int
t.selectWith(id, toggle: true); // toggle one (multi)
t.selectWith(id, range: true); // extend the range to id
t.selectAllVisible(); // multi only
t.removeSelected(); // delete every selected node + subtree (one undo step) β count
t.clearSelection();
removeSelected() drops any node whose ancestor is also selected (so a parent + child don't double-remove) and is a single undoable step.
Checkboxes
Independent of selection, turn on showCheckboxes: true for a tri-state check column β checking a folder checks all descendant leaves; a partially-checked folder shows a dash. onCheckedChanged reports the checked leaf ids; read them any time via controller.checkedLeafIds.
Search
With showSearch: true (and the toolbar), typing filters the tree to matching labels plus their ancestors (so matches stay reachable) and highlights the hit. Drive it from code with controller.setQuery('cash'); controller.filtering / matchCount report state. / or β/Ctrl + F focuses the field, Esc clears it.
Options & hooks
Tree(
roots: roots,
showToolbar: true, // search + expand/collapse all + undo/redo
showSearch: true,
showCheckboxes: false, // tri-state checks; onCheckedChanged gives leaf ids
showFooter: true,
showGuides: true, // the β β β indent guides
dense: false, // compact row height
editable: true, // inline rename (F2 / double-click) + structural edits
iconBuilder: (row) => row.node.isFolder ? Icons.folder : Icons.description,
trailingBuilder: (context, row) => null, // inject host widgets per row
labelBuilder: (context, row) => null, // fully replace the label cell
contextActions: (node) => [ // extra right-click items
TreeAction(label: 'Open', icon: Icons.open_in_new, onSelected: (c, n) {}),
],
onSelected: (n) {},
onActivated: (n) {}, // double-click / Enter on a leaf
onCheckedChanged: (ids) {}, // Set<TreeNodeId> of checked leaves
onChanged: (roots) {}, // structural change (add / remove / rename / move)
);
Driving it β TreeController
final t = TreeController(roots: roots, expanded: {'src'}, selected: 'main');
// structural edits (all undoable, all route through onChanged):
t.addChild('src', label: 'new.dart'); // β new id; expands, selects, renames
t.addChild('src', folder: true); // empty folder
t.addSibling('main', label: 'next.dart');
t.duplicate('main'); // fresh-id subtree as next sibling
t.remove('button');
t.removeSelected(); // group delete (multi)
// expansion / editing / history:
t.expandAll(); t.collapseAll(); t.toggle('ui');
t.beginEdit('main'); // inline rename
t.undo(); t.redo(); // t.canUndo / t.canRedo
// from inside row content (trailingBuilder / labelBuilder / a page):
TreeController.of<Account>(context)?.addChild(parentId);
TreeController.of<T>(context) returns the host controller (or null outside a tree), so any row widget can drive the tree.
Keyboard
Focus the tree body, then β press ? for the in-widget cheatsheet:
β β |
Move the cursor between visible rows |
β β |
Expand / step into a child Β· collapse / step out (RTL-mirrored) |
Home / End |
First / last visible row |
Enter |
Toggle a folder Β· activate a leaf (onActivated) |
Space |
Check / uncheck (when showCheckboxes) |
β§ + ββ Β· β/Ctrl-click Β· β§-click |
Extend / toggle / range-select (multi) |
β/Ctrl + A |
Select all visible (multi) |
F2 Β· Delete |
Rename Β· remove the focused node (editable) |
/ or β/Ctrl + F Β· Esc |
Focus search Β· clear it |
* / \ |
Expand all / collapse all |
β/Ctrl + Z / β§Z |
Undo / redo |
Navigating to an off-screen row (move, expand-into, or step-out) scrolls it into view, and arrow directions follow the visual direction in RTL.
4 Β· BrowserStyleTabBar
A browser-style workspace tab strip β pinned / closable / dirty tabs, drag-to-reorder, a context menu, an overflow dropdown, a dirty-close confirm dialog, and live mini-page previews (the page's real captured frame on hover). It renders the strip and the active page below it, and (by default) keeps every page's state alive across tab switches.
Quick start
// self-contained β owns a controller seeded with the default set:
const BrowserStyleTabBar();
// seed your own tabs:
BrowserStyleTabBar(tabsState: [
BrowserTab(id: 1, title: 'Chart of Accounts', kind: GLTabKind.ledger, pinned: true),
BrowserTab(id: 2, title: 'Journal Entry', kind: GLTabKind.doc, dirty: true),
BrowserTab(id: 3, title: 'Dashboard', kind: GLTabKind.chart),
]);
// external controller + custom page content:
BrowserStyleTabBar(
controller: myController,
pageBuilder: (ctx, tab) => MyPage(tab),
);
Provide tabsState (the widget owns a controller), or pass a controller: to drive / observe it externally. pageBuilder supplies the content for each tab (used both in the active surface and, scaled, in the hover preview); omit it to get the built-in GLTabPage per kind.
The tab β BrowserTab
BrowserTab({
required int id, // stable identity
required String title,
required GLTabKind kind, // ledger Β· doc Β· store Β· chart Β· user Β· globe
bool dirty = false, // unsaved-changes dot + close-confirm
bool pinned = false, // icon-only, anchored on the start edge
});
GLTabKind drives the leading icon, the preview layout and the default page; pinned tabs render icon-only and sort to the start; a dirty tab shows an unsaved dot and triggers a confirm dialog on close.
State-preserving pages
By default every tab's page is built once and kept mounted in an IndexedStack, so switching tabs preserves scroll position, form input and controllers with no rebuild β switching only changes the visible index (each page is held alive even offstage). Opt into the cheaper build-only-the-active-page behaviour β pages reset when revisited β with lazyPages: true.
BrowserStyleTabBar(controller: c, pageBuilder: buildPage); // state survives switching
BrowserStyleTabBar(controller: c, pageBuilder: buildPage, lazyPages: true); // rebuild on each visit
Embedding options
BrowserStyleTabBar(
controller: c,
pageBuilder: buildPage,
showChrome: true, // the bordered, rounded card. false = edge-to-edge in an app shell
fillContent: false, // true = page fills all height (full-window workspace); false caps at 440px
scrollContent: true, // wrap the page in a vertical scroll view (false = page scrolls itself)
contentPadding: const EdgeInsets.all(24),
contentBackground: null, // defaults to the theme surface
onAddTab: null, // intercept the + button (else the controller's add())
);
Driving it β BrowserStyleTabBarController
final tabs = BrowserStyleTabBarController(tabs: [...], activeId: 2);
tabs.add(title: 'New report', kind: GLTabKind.chart); // β new id; activates
tabs.select(otherId);
tabs.setDirty(myId, true);
tabs.togglePin(myId);
tabs.rename(myId, 'Q3 Trial Balance');
tabs.duplicate(myId);
tabs.reorder(fromId, toId);
tabs.close(myId); tabs.closeOthers(myId); tabs.closeToRight(myId);
// reads:
tabs.tabs; tabs.activeTab; tabs.length; tabs.ordered; // pinned-first order
tabs.canCloseOthers(id); tabs.canCloseRight(id);
// any page can drive the strip:
BrowserStyleTabBarController.of(context)?.add(title: 'Detail', kind: GLTabKind.doc);
Full op set: select Β· add Β· close Β· closeOthers Β· closeToRight Β· duplicate Β· togglePin Β· setPinned Β· reorder Β· setDirty Β· rename Β· mutate (an escape hatch β edit inside the callback, it notifies after). of(context) returns null outside a tab bar, so pages stay reusable stand-alone; read(context) is the non-listening variant for callbacks / initState.
Keyboard
Focus the strip: β β move between tabs (visual direction, RTL-aware), Home / End jump to the first / last tab. Right-click (or long-press) any tab for the context menu β close, close others, close to the right, duplicate, pin / unpin.
5 Β· NavigationSidebar
A themeable, responsive app navigation sidebar β the GeniusLink web nav ported whole. One data model (titled sections of a node tree) renders in three modes that the host picks from the available width: a full expanded labelled tree with β β β connectors, an icon-only rail whose modules open a grouped hover flyout, and an off-canvas drawer with a scrim for small screens. Active-screen highlight, auto-expanding ancestors, badges (count / status), two-key shortcut hints, optional header/footer slots β all theme-aware and RTL-mirrored.
Quick start
// self-contained β owns a controller seeded with your sections:
NavigationSidebar<String>(
sections: mySections, // List<NavSection<String>>
active: 'accounts',
mode: NavSidebarMode.expanded,
onNavigate: (node) => openScreen(node.value!),
);
Each node is a NavNode<T> carrying a typed value; compose a List<NavSection<T>> to describe the whole sidebar. A node's role is derived from its position β a depth-0 leaf is a flat direct destination, a depth-0 branch is a collapsible module, a nested branch is a group header, and a nested leaf is an item (boxed icon):
final sections = <NavSection<String>>[
NavSection(title: 'Overview', items: [
NavNode(id: 'dashboard', label: 'Dashboard', icon: Icons.dashboard_outlined,
value: 'dashboard', shortcut: ['g', 'd']),
]),
NavSection(title: 'Finance', items: [
NavNode(id: 'accountsHub', label: 'Accounts', icon: Icons.menu_book_outlined, children: [
NavNode(id: 'coa', label: 'Chart of Accounts', children: [
NavNode(id: 'accounts', label: 'Chart of Accounts', icon: Icons.menu_book_outlined, value: 'accounts'),
NavNode(id: 'accountTree', label: 'Account Tree', icon: Icons.account_tree_outlined, value: 'accountTree',
badge: NavBadge('3'), shortcut: ['g', 't']),
]),
]),
]),
];
Responsive β three modes
The view doesn't guess the layout; the host derives the mode from the available width (a LayoutBuilder + NavSidebarBreakpoints does it in a line) and the same controller drives all three:
LayoutBuilder(builder: (context, c) {
final mode = const NavSidebarBreakpoints().modeFor(c.maxWidth); // expanded β₯1200 Β· rail β₯768 Β· else drawer
if (mode == NavSidebarMode.drawer) {
return Stack(children: [
page,
Positioned.fill(child: NavigationSidebar<String>(controller: nav, mode: NavSidebarMode.drawer)),
]); // open via nav.openDrawer(); a tap on a destination navigates *and* dismisses
}
return Row(children: [
NavigationSidebar<String>(controller: nav, mode: mode), // expanded or rail
Expanded(child: page),
]);
});
Options & slots
NavigationSidebar<T>(
controller: nav, // or sections + active
mode: NavSidebarMode.expanded, // expanded Β· rail Β· drawer
showGuides: true, // the β β β connector lines
railFlyouts: true, // module hover flyouts in the rail
drawerTitle: 'Navigation',
header: (ctx, collapsed) => Brand(collapsed: collapsed), // logo slot
footer: (ctx, collapsed) => HelpCard(collapsed: collapsed),
onNavigate: (node) {},
);
Driving it β NavigationSidebarController
final nav = NavigationSidebarController<String>(
sections: sections, active: 'accounts',
);
nav.navigate('settingsHub'); // sets active + auto-opens ancestors + closes the drawer
nav.toggleCollapsed(); // expanded β rail
nav.openDrawer(); // mobile
nav.expandAll();
// from inside page content:
NavigationSidebarController.of<String>(context)?.navigate('dashboard');
Badges carry a tone β NavBadge('Live', tone: NavBadgeTone.success) β drawn as a pill on the row (a dot on a collapsed module / rail icon). See example/lib/navigation_sidebar_demo.dart for the full shell (app bar + sidebar + faux page) with live Light/Dark, LTR/RTL and a device-width simulator.
Theming
Every component is self-contained: all of its surfaces live in one ThemeExtension with .light / .dark presets (lerped on theme change). Instance fields are the swappable surfaces (bg / surface / hover / border / fg1..fg4); static consts are the brand constants (accent + semantic palette, font families Manrope / Inter / JetBrainsMono, radii, shadows, motion).
ThemeData(extensions: const [EditableTableThemeData.light]);
final t = EditableTableThemeData.of(context); // falls back to .dark
// tweak a preset:
EditableTableThemeData.light.copyWith(surface: const Color(0xFFFBFBFD));
Brand tokens: blue #4A7CFF Β· green #1DB88A Β· orange #F97316 Β· 4px radii Β· 40px controls.
RTL & internationalisation
Wrap any component in Directionality(textDirection: TextDirection.rtl, β¦) β strips, guides, gutters, and menus all mirror. The example's ERP Console flips EN β AR (LTR β RTL) live.
Architecture
lib/
βββ geniuslink_design_system.dart unified barrel (exports the 5 below)
βββ geniuslink_browser_tabs.dart Β· BrowserStyleTabBar barrel
βββ geniuslink_editable_table.dart Β· EditableTable barrel
βββ geniuslink_readable_table.dart Β· ReadableTable barrel (shares the editable theme)
βββ geniuslink_tree.dart Β· Tree barrel
βββ geniuslink_navigation_sidebar.dart Β· NavigationSidebar barrel
βββ design_system/components/
βββ data/
β βββ editable_table_models.dart Model β columns, cell ref, formatters
β βββ editable_table_columns.dart Model β typed column subclasses
β βββ editable_table_controller.dart Controller β ChangeNotifier + scope
β βββ editable_table_theme.dart Theme β ThemeExtension (Editable + Readable)
β βββ editable_table.dart View β EditableTable widget
β βββ readable_table_models.dart Model β ReadableColumn<T>, cell, enums
β βββ readable_table_controller.dart Controller β ChangeNotifier + scope
β βββ readable_table.dart View β ReadableTable<T> (generic Β· MVC)
β βββ tree_*.dart Tree β model Β· controller Β· theme Β· view
βββ navigation/
βββ browser_style_tab_bar*.dart BrowserStyleTabBar β MVC + overlays + pages
βββ navigation_sidebar_*.dart NavigationSidebar β model Β· controller Β· theme Β· view
Each component follows Model β Controller β View β Theme: immutable data, a ChangeNotifier as the single source of truth, a thin view that forwards every gesture/keystroke, and a ThemeExtension. Controllers are exposed to descendant page content via an InheritedNotifier scope, so any child can drive the component.
License
MIT Β© GeniusLink.
Libraries
- design_system/components/data/editable_table
- design_system/components/data/editable_table_columns
- design_system/components/data/editable_table_controller
- design_system/components/data/editable_table_generic
- design_system/components/data/editable_table_generic_view
- design_system/components/data/editable_table_models
- design_system/components/data/editable_table_theme
- design_system/components/data/readable_table
- design_system/components/data/readable_table_cells
- design_system/components/data/readable_table_controller
- design_system/components/data/readable_table_models
- design_system/components/data/tree
- design_system/components/data/tree_controller
- design_system/components/data/tree_models
- design_system/components/data/tree_theme
- design_system/components/key_directions
- Direction-aware keyboard helpers shared by every component that navigates a
horizontal axis with the arrow keys (
BrowserStyleTabBar,EditableTable,ReadableTable,Tree). - design_system/components/navigation/browser_style_tab_bar
- design_system/components/navigation/browser_style_tab_bar_controller
- design_system/components/navigation/browser_style_tab_bar_theme
- design_system/components/navigation/tab_models
- design_system/components/navigation/tab_overlays
- design_system/components/navigation/tab_pages
- geniuslink_browser_tabs
- geniuslink_design_system
- geniuslink_editable_table
- geniuslink_editable_table_generic
- geniuslink_readable_table
- geniuslink_tree