GeniusLink Design System

pub package flutter style license

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.html in 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 ThemeExtension with ready-made .light / .dark presets. No global token file required.
  • πŸ›οΈ Strict MVC β€” immutable models, a ChangeNotifier controller as the single source of truth, and a thin view. Drive any component from outside, or from its own page content via an InheritedNotifier scope.
  • ⌨️ 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 return value[i]. That's exactly how the desktop GLTable wrapper keeps its List<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.

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