nextgenSOFTWARE
SOFTWARE NEXT GENERATION

NgDataGrid — Data grid

Live component previewTry the controls · demonstration data
Open preview ↗

Manual · Getting started · Code examples · Models

When to use it

Page, search and sort local or remote records.

Appearance and live example

Try the Blazor example · Flutter example code

The live Blazor preview is embedded above. Use Reset to restore its initial state. Flutter implementation code is provided below; run the Flutter gallery from the installed developer workspace. Dialogs and drawers start closed; use their Open button. Tooltips appear on hover or keyboard focus.

Behavior and practical rules

ReadData/readData enables asynchronous page loading; Items/items is used only in local mode. The request contains 1-based Page/page, PageSize/pageSize, Search/search and an ordered immutable Sorts/sorts list. Return only the requested page and Total/total after filtering, before paging. The demo simulates a remote data adapter with a delay; replace its body with your HTTP/database call. A newer request or source replacement invalidates older results and errors; Blazor also passes a cancellation token. Flutter ignores stale futures but does not abort the transport. Loading hides old rows and blocks selection. Failure shows ErrorText/errorText and RetryText/retryText without exposing exception details. Change DataVersion/dataVersion to reload page 1; keep reader identity stable. Blazor ReloadAsync reloads the current page. A shrinking total clamps the page and fetches it again. Invalid totals, duplicate IDs and oversized pages become retryable errors. MultiSort/multiSort appends clicked columns by priority; clicking again toggles direction without moving priority. Clear sorting restores source order. Local sorting is stable. Row/column IDs must be unique and nonblank. Selection remains parent-owned and page selection preserves hidden/protected IDs. CanSelect/canSelect and SelectionReadOnly/selectionReadOnly still apply. Lazy group paging and batch/cell editing remain open. Remote grouping, single-record editing and CSV/TSV export use the explicit adapters below. See data workspace.

Local grouping and paging

GroupBy/groupBy enables one local grouping level over string keys; empty strings are a valid group and keys are case-sensitive. Search and sort run first, then groups gather matching records in first-appearance order while preserving sorted order within each group, then the flattened grouped rows are paged. Headers do not consume page slots. A group crossing a page boundary repeats its header; the count is the complete filtered group count, not only the current page. GroupText/groupText formats the key and count as plain text. Selection/page selection covers data rows only and retains off-page IDs. Enabling/disabling grouping resets to page 1; changing a grouping rule in-place requires DataVersion/dataVersion to reset paging. Selectors must be pure. GroupBy/groupBy cannot be combined with ReadData/readData: use ReadGroupedData/readGroupedData for the group-aware server protocol, never approximate a full group from one server page. Lazy group paging remains open; remote grouping, editing and export use the separate contracts below.

Nested groups

GroupSelectors/groupSelectors accepts up to eight ordered string selectors for nested groups; use it instead of GroupBy/groupBy, never together. Each level keeps first-appearance order. Change DataVersion/dataVersion when changing the level order or rule; the demo uses its grouping switches as the version.

Controlled expansion

CollapsibleGroups/collapsibleGroups opts into controlled expansion. CollapsedGroups/collapsedGroups stores full-path IDs from NgDataOperations.GroupId(path) / ngGroupId(path), not raw keys. IDs use length-prefixed UTF-16 segments, so repeated labels, empty keys and separator characters do not collide. Store the fresh set from CollapsedGroupsChanged/onCollapsedGroupsChanged to accept a request, or ignore it to reject. Hidden and unknown IDs survive updates. GroupExpansionReadOnly/groupExpansionReadOnly and missing callbacks disable controls; external collapsed state is still displayed. Turning collapsibility off ignores, but does not erase, stored state. GroupToggleLabel/groupToggleLabel receives the path and current expanded state; ExpandGroupsText/expandGroupsText and CollapseGroupsText/collapseGroupsText localize actions.

Bulk actions and selection

Bulk actions affect every currently filtered group, including off-page descendants; filtered-out/unknown IDs are retained. Collapse is a view operation after paging: record ranges, page counts and full group counts stay unchanged, and each visited page repeats the necessary ancestor headers. Fully collapsed pages show headers only, not an empty-result message. Page selection includes only eligible rendered data rows; existing hidden selections are preserved.

Group summaries

GroupAggregates/groupAggregates supplies NgGroupAggregate descriptors: title, numeric value selector, operation (sum, average, min, max, count) and optional format callback. Aggregates use all filtered records at each level, independently of paging and collapse; parent averages are record-weighted. Null/NaN/infinite values are ignored; count counts finite numeric values, while the header count counts records. Empty sum/count are zero, empty average/min/max are missing (displayed as an em dash). Results use double precision, not financial decimal arithmetic. Invalid operations fail validation. Headers and aggregate formatting are plain text; callbacks must be pure. The grid uses native keyboard buttons for toggling; no screen-reader certification or native-target parity is claimed.

HTTP grouped data

ReadGroupedData/readGroupedData uses NgGroupedDataRequest (page, pageSize, search, sorts, groupKeys) and NgGroupedDataResult<T> (items with full paths, filtered total and full-count/summary metadata for the page groups). RemoteGroupKeys/remoteGroupKeys contains one to eight distinct field identifiers. Changing keys resets paging and invalidates pending work. Local selectors/aggregates and ordinary ReadData cannot be combined with this mode. Counts and summaries come from the server; malformed envelopes fail atomically into ErrorText and Retry. Collapse is local and retains record paging. Bulk actions explicitly affect page groups only; off-page state is preserved. RemoteSummaryText/remoteSummaryText formats numeric server summaries as plain text. The HTTP example calls the read-only Blazor gallery fixture endpoint from both platforms; keep port 5180 running for Flutter. Put the extra Dart imports at file scope (the transport is Web-specific); the remaining snippet belongs in a State class. No database provider is bundled. See Remote grouped data for JSON schemas, validation rules, cancellation, adapter helpers and failure/shrink exercises.

Create, edit and delete

Editing/editing accepts NgGridEditing<T>: text-field descriptors, a draft factory, validation, asynchronous persistence and per-record permissions. The editable inventory demonstrates required and integer validation, duplicate-name server errors, protected records, delayed saves, generic failures, retry and cancellation. See Grid editing for lifecycle, remote refresh, concurrency and application responsibilities.

CSV and TSV export

Export/export accepts NgGridExport<T> with an output callback, ordered column keys, optional plain-text formatting, delimiter, headers, BOM and limits. Page exports rendered rows; Filtered exports the complete query; Selected intersects the query with caller-owned selected IDs. Full remote export requires ReadExportData/readExportData; ordinary page reads are never silently treated as complete datasets. See Grid export for exact scope, escaping, formula protection, adapter, cancellation and browser-download contracts.

Virtual scrolling

Virtualize/virtualize enables fixed-height windowed rendering. RowHeight/rowHeight defaults to 48; ViewportHeight/viewportHeight defaults to 384. Local Grid covers the filtered result; remote Grid retains its current-page adapter and pager; trees virtualize expanded loaded nodes. See Virtual data for complete selection, export, focus, layout and performance contracts and 10,000-record runnable demos. Continuous remote loading uses ReadRange/readRange; see Remote ranges.

Continuous remote ranges

ReadRange/readRange with Virtualize/virtualize scrolls a flat server result while retaining one bounded window. Requests carry a zero-based startIndex, count, search and ordered sorts; responses echo startIndex, supply exactly the requested records within total, and provide total (0–100,000). Stable unique row IDs and deterministic server ordering are required. Page selection/export are labelled Loaded window and include overscan. Full remote export still requires its own adapter. See Remote ranges for executable examples, JSON, error/retry, cancellation, limits and server consistency rules.

Blazor example

Razor / Blazor
@using System.Net.Http.Json
@inject System.Net.Http.IHttpClientFactory HttpFactory
@inject NavigationManager Navigation
<h3>Grid export</h3>
<p>Prepare a CSV or TSV file, inspect its row count, then download. Local export respects the current query; remote full export uses a separate bounded adapter.</p>
<NgCheckBox Label="Export quantity column" @bind-Value="exportQuantity"/>
<NgCheckBox Label="Use tab-separated format" @bind-Value="exportTsv"/>
<NgCheckBox Label="Fail exports" @bind-Value="exportFail"/>
<NgCheckBox Label="Lock export" @bind-Value="exportLocked"/>
@if(lastExport is {} file){<p>Last export: @file.RowCount rows · @string.Join(", ",file.ColumnKeys)</p><a class="ng-button" href="@($"data:{file.MimeType};base64,{Convert.ToBase64String(file.Bytes)}")" download="@file.FileName">Download last export</a><pre>@file.Content[..Math.Min(file.Content.Length,1200)]</pre>}
<h3>Local grouped inventory</h3>
<NgCheckBox Label="Group by product" @bind-Value="grouped"/>
<NgCheckBox Label="Also group by availability" @bind-Value="nested"/>
<NgCheckBox Label="Show group totals" @bind-Value="totals"/>
<NgCheckBox Label="Lock group expansion" @bind-Value="groupLocked"/>
<NgDataGrid TItem="Product" Label="Grouped inventory" Export="LocalExport" Items="rows" Columns="columns" RowId="r=>r.Id" GroupSelectors="Grouping" DataVersion="@($"{grouped}/{nested}")" CollapsibleGroups="true" @bind-CollapsedGroups="collapsed" GroupExpansionReadOnly="groupLocked" GroupAggregates="@(totals?aggregates:[])" GroupText="GroupCaption" MultiSort="true" @bind-SelectedIds="localSelected" CanSelect="r=>r.Count<100"/>
<p>Local selection: @string.Join(", ",localSelected.Order())</p>
<h3>Editable inventory</h3>
<p>Drafts are isolated. Quantities 100 and above are protected. Try a blank name, invalid quantity, a save failure and a retry.</p>
<NgCheckBox Label="Lock editing" @bind-Value="editLocked"/>
<NgCheckBox Label="Fail saves" @bind-Value="editFail"/>
<NgDataGrid TItem="Product" Label="Editable inventory" Items="editableRows" Columns="columns" RowId="r=>r.Id" Editing="Editor" GroupBy="r=>r.Name" Selectable="false"/>
<p>Successful writes: @writes</p>
<h3>HTTP grouped inventory</h3>
<p>Live requests to the local gallery API. Counts and summaries are computed before server paging.</p>
<NgCheckBox Label="Remote nested groups" @bind-Value="remoteNested"/>
<NgCheckBox Label="Fail grouped requests" @bind-Value="groupFail"/>
<NgCheckBox Label="Malformed grouped response" @bind-Value="groupMalformed"/>
<NgCheckBox Label="Small remote dataset" @bind-Value="groupSmall"/>
<NgButton OnClick="()=>groupVersion++">Reload grouped source</NgButton>
<NgDataGrid TItem="Product" Label="HTTP grouped inventory" ReadGroupedData="ReadGrouped" RemoteGroupKeys="@(remoteNested?new[]{"name","availability"}:new[]{"name"})" DataVersion="groupVersion" MultiSort="true" Columns="columns" RowId="r=>r.Id" CollapsibleGroups="true" @bind-CollapsedGroups="remoteCollapsed" @bind-SelectedIds="remoteSelected" CanSelect="r=>r.Count<100"/>
<p>Grouped requests: @groupRequests · @groupQuery</p>
<h3>Remote paging adapter</h3>
<NgCheckBox Label="Fail requests" @bind-Value="fail"/>
<NgButton OnClick="()=>version++">Reload source</NgButton>
<NgDataGrid TItem="Product" ReadData="Read" Export="RemoteExport" DataVersion="version" MultiSort="true" Columns="columns" RowId="r=>r.Id" @bind-SelectedIds="selected" CanSelect="r=>r.Count<100" PageSizes="new[]{5,10,20}"/>
<p>Requests: @requests · Latest query: @query</p>
@code {
 private bool exportQuantity=true,exportTsv,exportFail,exportLocked;private NgGridExportFile? lastExport;
 private NgGridExport<Product> LocalExport=>new(){OnExport=ReceiveExport,ColumnKeys=exportQuantity?["name","count"]:["name"],FileName=exportTsv?"inventory.tsv":"inventory.csv",Delimiter=exportTsv?"\t":",",ReadOnly=exportLocked,ScopeText="Local export scope",Format=(key,r)=>key=="count"?r.Count.ToString(System.Globalization.CultureInfo.InvariantCulture):r.Name};
 private NgGridExport<Product> RemoteExport=>LocalExport with {ReadExportData=ReadExport,ScopeText="Remote export scope"};
 private async Task ReceiveExport(NgGridExportFile file,CancellationToken token){token.ThrowIfCancellationRequested();if(exportFail)throw new InvalidOperationException("Private delivery detail");lastExport=file;await InvokeAsync(StateHasChanged);}
 private async Task<IReadOnlyList<Product>> ReadExport(NgGridExportRequest request,CancellationToken token){
   await Task.Delay(600,token);if(exportFail)throw new InvalidOperationException("Private adapter detail");
   var result=NgDataOperations.Query(rows,columns,request.Search,request.Sorts);
   if(request.Scope==NgGridExportScope.Selected)result=result.Where(r=>request.SelectedIds.Contains(r.Id)).ToArray();
   if(result.Count>request.MaxRows)throw new InvalidOperationException("Too many records");return result;
 }
 public record Product(string Id,string Name,int Count);
 private bool grouped=true,nested=true,totals=true,groupLocked;
 private HashSet<string> collapsed=[];
 private IReadOnlyList<Func<Product,string>> Grouping=>!grouped?[]:nested?[ProductGroup,r=>r.Count<100?"Available":"Protected"]:[ProductGroup];
 private static readonly NgGroupAggregate<Product>[] aggregates=[new("Total quantity",r=>r.Count),new("Average",r=>r.Count,"average",v=>v?.ToString("0.0")??"—")]; private HashSet<string> localSelected=[]; private string ProductGroup(Product row)=>row.Name; private string GroupCaption(string key,int count)=>$"{key}: {count} matching records";
 private List<Product> editableRows=[new("e1","Chair",12),new("e2","Desk",24),new("e3","Protected",100)];
 private bool editLocked,editFail;private int writes,nextEditId=4;
 private NgGridEditing<Product> Editor=>new(){
   Fields=[new("name","Product name",true),new("quantity","Product quantity",true)],
   Draft=r=>new Dictionary<string,string>{{"name",r?.Name??""},{"quantity",r?.Count.ToString()??"0"}},
   ReadOnly=editLocked,CanUpdate=r=>r.Count<100,CanDelete=r=>r.Count<100,
   Validate=v=>!int.TryParse(v["quantity"],out var n)||n<0?"Quantity must be a nonnegative integer.":null,
   Commit=SaveProduct
 };
 private async Task<string?> SaveProduct(NgGridEditOperation operation,string? id,IReadOnlyDictionary<string,string> values,CancellationToken token){
   await Task.Delay(600,token);
   if(editFail)throw new InvalidOperationException("Private storage detail");
   if(editLocked)return "Editing is locked.";
   var existing=editableRows.FirstOrDefault(r=>r.Id==id);
   if(operation!=NgGridEditOperation.Create && (existing is null || existing.Count>=100))return "Record is missing or protected.";
   if(operation==NgGridEditOperation.Delete)editableRows.RemoveAll(r=>r.Id==id);
   else{
     var name=values["name"].Trim();var quantity=int.Parse(values["quantity"]);
     if(editableRows.Any(r=>r.Id!=id && string.Equals(r.Name,name,StringComparison.OrdinalIgnoreCase)))return "Product name already exists.";
     var next=new Product(id??$"e{nextEditId++}",name,quantity);
     if(operation==NgGridEditOperation.Create)editableRows.Add(next);else editableRows[editableRows.FindIndex(r=>r.Id==id)]=next;
   }
   writes++;await InvokeAsync(StateHasChanged);return null;
 }
 private bool remoteNested=true,groupFail,groupMalformed,groupSmall;private int groupVersion,groupRequests;private string groupQuery="";
 private HashSet<string> remoteCollapsed=[],remoteSelected=[];
 private async Task<NgGroupedDataResult<Product>> ReadGrouped(NgGroupedDataRequest request,CancellationToken token){
   groupRequests++;groupQuery=$"HTTP page {request.Page} / {string.Join(" > ",request.GroupKeys)} / {request.Search}";
   try{
     using var client=HttpFactory.CreateClient();
     var url=new Uri(new Uri(Navigation.BaseUri),$"api/grouped-inventory?fail={groupFail.ToString().ToLowerInvariant()}&malformed={groupMalformed.ToString().ToLowerInvariant()}&small={groupSmall.ToString().ToLowerInvariant()}");
     using var response=await client.PostAsJsonAsync(url,request,token);response.EnsureSuccessStatusCode();
     return await response.Content.ReadFromJsonAsync<NgGroupedDataResult<Product>>(cancellationToken:token)??throw new InvalidOperationException("Empty response");
   }finally{if(!token.IsCancellationRequested)await InvokeAsync(StateHasChanged);}
 }
 private bool fail; private int version,requests; private string query="";
 private HashSet<string> selected=[];
 private static readonly Product[] rows=Enumerable.Range(1,40).Select(i=>new Product($"p{i}",i%2==0?"Desk":"Chair",i*4)).ToArray();
 private static readonly NgDataColumn<Product>[] columns=[new("name","Name",r=>r.Name),new("count","Quantity",r=>r.Count)];
 private async Task<NgDataResult<Product>> Read(NgDataRequest request,CancellationToken token){
   requests++;query=$"{request.Search} / page {request.Page} / {request.Sorts.Count} sorts";
   await Task.Delay(350,token);await InvokeAsync(StateHasChanged);
   if(fail)throw new InvalidOperationException("Demo failure");
   var result=NgDataOperations.Query(rows,columns,request.Search,request.Sorts);
   return new(result.Skip((request.Page-1)*request.PageSize).Take(request.PageSize).ToArray(),result.Count);
 }
}

Flutter example

Dart / Flutter
import 'dart:convert';
import 'dart:js_interop';
import 'package:web/web.dart' as web;

bool exportQuantity=true,exportTsv=false,exportFail=false,exportLocked=false;
NgGridExportFile? lastExport;
NgGridExport<({String id,String name,int count})> exportConfig(bool remote)=>NgGridExport(
  onExport:receiveExport,readExportData:remote?readExport:null,columnKeys:exportQuantity?['name','count']:['name'],
  fileName:exportTsv?'inventory.tsv':'inventory.csv',delimiter:exportTsv?'\t':',',readOnly:exportLocked,
  scopeText:remote?'Remote export scope':'Local export scope',format:(key,r)=>key=='count'?'${r.count}':r.name);
Future<void> receiveExport(NgGridExportFile file)async{
  if(exportFail)throw StateError('Private delivery detail');if(mounted)setState(()=>lastExport=file);
}
Future<List<({String id,String name,int count})>> readExport(NgGridExportRequest request)async{
  await Future<void>.delayed(const Duration(milliseconds:600));if(exportFail)throw StateError('Private adapter detail');
  var result=NgDataOperations.queryMany(rows,columns,request.search,request.sorts);
  if(request.scope==NgGridExportScope.selected)result=result.where((r)=>request.selectedIds.contains(r.id)).toList();
  if(result.length>request.maxRows)throw StateError('Too many records');return result;
}
void downloadExport(NgGridExportFile file){
  final anchor=web.HTMLAnchorElement()..href='data:${file.mimeType};base64,${base64Encode(file.bytes)}'..download=file.fileName;
  anchor.click();
}
final editableRows=<( {String id,String name,int count})>[(id:'e1',name:'Chair',count:12),(id:'e2',name:'Desk',count:24),(id:'e3',name:'Protected',count:100)];
bool editLocked=false,editFail=false;
int writes=0,nextEditId=4;
NgGridEditing<({String id,String name,int count})> get editor=>NgGridEditing(
  fields:[NgGridEditField('name','Product name',required:true),NgGridEditField('quantity','Product quantity',required:true)],
  draft:(r)=>{'name':r?.name??'','quantity':'${r?.count??0}'},readOnly:editLocked,canUpdate:(r)=>r.count<100,canDelete:(r)=>r.count<100,
  validate:(v)=>int.tryParse(v['quantity']??'')==null || int.parse(v['quantity']!)<0?'Quantity must be a nonnegative integer.':null,
  commit:saveProduct);
Future<String?> saveProduct(NgGridEditOperation operation,String? id,Map<String,String> values)async{
  await Future<void>.delayed(const Duration(milliseconds:600));
  if(!mounted)return 'Editor closed.';
  if(editFail)throw StateError('Private storage detail');
  if(editLocked)return 'Editing is locked.';
  final index=editableRows.indexWhere((r)=>r.id==id);
  if(operation!=NgGridEditOperation.create && (index<0 || editableRows[index].count>=100))return 'Record is missing or protected.';
  if(operation==NgGridEditOperation.delete){setState(()=>editableRows.removeAt(index));}
  else{
    final name=values['name']!.trim(),quantity=int.parse(values['quantity']!);
    if(editableRows.any((r)=>r.id!=id && r.name.toLowerCase()==name.toLowerCase()))return 'Product name already exists.';
    final next=(id:id??'e${nextEditId++}',name:name,count:quantity);
    setState((){if(operation==NgGridEditOperation.create){editableRows.add(next);}else{editableRows[index]=next;}});
  }
  setState(()=>writes++);return null;
}
bool remoteNested=true,groupFail=false,groupMalformed=false,groupSmall=false;
int groupVersion=0,groupRequests=0;
String groupQuery='';
Set<String> remoteCollapsed={},remoteSelected={};
Future<NgGroupedDataResult<({String id,String name,int count})>> readGrouped(NgGroupedDataRequest request)async{
  groupRequests++;groupQuery='HTTP page ${request.page} / ${request.groupKeys.join(" > ")} / ${request.search}';
  try{
    final url='http://127.0.0.1:5180/api/grouped-inventory?fail=$groupFail&malformed=$groupMalformed&small=$groupSmall';
    final response=await web.window.fetch(url.toJS,web.RequestInit(method:'POST',headers:{'Content-Type':'application/json'}.jsify()! as JSObject,body:jsonEncode(request.toJson()).toJS)).toDart;
    if(!response.ok)throw StateError('Grouped request failed');
    final json=jsonDecode((await response.text().toDart).toDart) as Map<String,dynamic>;
    return NgGroupedDataResult.fromJson(json,(row)=>(id:row['id'] as String,name:row['name'] as String,count:row['count'] as int));
  }finally{if(mounted)setState((){});}
}
final rows = List.generate(40, (i) => (id: 'p${i+1}', name: i.isOdd ? 'Desk' : 'Chair', count: (i+1)*4));
final columns = <NgDataColumn<({String id, String name, int count})>>[
  NgDataColumn('name', 'Name', (r) => r.name), NgDataColumn('count', 'Quantity', (r) => r.count)];
Set<String> localSelected = {}; bool grouped=true,nested=true,totals=true,groupLocked=false;
Set<String> collapsed={};
final aggregates=<NgGroupAggregate<({String id,String name,int count})>>[NgGroupAggregate('Total quantity',(r)=>r.count.toDouble()),NgGroupAggregate('Average',(r)=>r.count.toDouble(),operation:'average',format:(v)=>v?.toStringAsFixed(1)??'—')];
Set<String> selected = {};
bool fail = false;
int version = 0, requests = 0;
String query = '';
Future<NgDataResult<({String id, String name, int count})>> read(NgDataRequest request) async {
  requests++; query = '${request.search} / page ${request.page} / ${request.sorts.length} sorts';
  await Future<void>.delayed(const Duration(milliseconds: 350)); if (mounted) setState(() {});
  if (fail) throw StateError('Demo failure');
  final result = NgDataOperations.queryMany(rows, columns, request.search, request.sorts);
  return NgDataResult(result.skip((request.page-1)*request.pageSize).take(request.pageSize).toList(), result.length);
}
Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  const Text('Grid export'),
  const Text('Prepare CSV or TSV, inspect the result, then download. Remote full export uses a separate bounded adapter.'),
  NgCheckBox(label:'Export quantity column',value:exportQuantity,onChanged:(v)=>setState(()=>exportQuantity=v)),
  NgCheckBox(label:'Use tab-separated format',value:exportTsv,onChanged:(v)=>setState(()=>exportTsv=v)),
  NgCheckBox(label:'Fail exports',value:exportFail,onChanged:(v)=>setState(()=>exportFail=v)),
  NgCheckBox(label:'Lock export',value:exportLocked,onChanged:(v)=>setState(()=>exportLocked=v)),
  if(lastExport case final file?)...[Text('Last export: ${file.rowCount} rows · ${file.columnKeys.join(", ")}'),NgButton(onPressed:()=>downloadExport(file),child:const Text('Download last export')),SelectableText(file.content.substring(0,file.content.length>1200?1200:file.content.length))],
  const Text('Local grouped inventory'),
  NgCheckBox(label:'Group by product',value:grouped,onChanged:(v)=>setState(()=>grouped=v)),
  NgCheckBox(label:'Also group by availability',value:nested,onChanged:(v)=>setState(()=>nested=v)),
  NgCheckBox(label:'Show group totals',value:totals,onChanged:(v)=>setState(()=>totals=v)),
  NgCheckBox(label:'Lock group expansion',value:groupLocked,onChanged:(v)=>setState(()=>groupLocked=v)),
  NgDataGrid<({String id,String name,int count})>(label:'Grouped inventory',export:exportConfig(false),items:rows,columns:columns,rowId:(r)=>r.id,groupSelectors:!grouped?[]:nested?[(r)=>r.name,(r)=>r.count<100?'Available':'Protected']:[(r)=>r.name],dataVersion:'$grouped/$nested',collapsibleGroups:true,collapsedGroups:collapsed,onCollapsedGroupsChanged:(v)=>setState(()=>collapsed=v),groupExpansionReadOnly:groupLocked,groupAggregates:totals?aggregates:[],groupText:(key,count)=>'$key: $count matching records',multiSort:true,selectedIds:localSelected,onSelectionChanged:(v)=>setState(()=>localSelected=v),canSelect:(r)=>r.count<100),
  Text('Local selection: ${(localSelected.toList()..sort()).join(", ")}'),
  const Text('Editable inventory'),
  const Text('Drafts are isolated. Quantities 100 and above are protected. Try validation, failure and retry.'),
  NgCheckBox(label:'Lock editing',value:editLocked,onChanged:(v)=>setState(()=>editLocked=v)),
  NgCheckBox(label:'Fail saves',value:editFail,onChanged:(v)=>setState(()=>editFail=v)),
  NgDataGrid<({String id,String name,int count})>(label:'Editable inventory',items:editableRows,columns:columns,rowId:(r)=>r.id,groupBy:(r)=>r.name,editing:editor),
  Text('Successful writes: $writes'),
  const Text('HTTP grouped inventory'),
  const Text('Live requests to the local gallery API. Start the Blazor gallery on port 5180.'),
  NgCheckBox(label:'Remote nested groups',value:remoteNested,onChanged:(v)=>setState(()=>remoteNested=v)),
  NgCheckBox(label:'Fail grouped requests',value:groupFail,onChanged:(v)=>setState(()=>groupFail=v)),
  NgCheckBox(label:'Malformed grouped response',value:groupMalformed,onChanged:(v)=>setState(()=>groupMalformed=v)),
  NgCheckBox(label:'Small remote dataset',value:groupSmall,onChanged:(v)=>setState(()=>groupSmall=v)),
  NgButton(onPressed:()=>setState(()=>groupVersion++),child:const Text('Reload grouped source')),
  NgDataGrid<({String id,String name,int count})>(label:'HTTP grouped inventory',readGroupedData:readGrouped,remoteGroupKeys:remoteNested?['name','availability']:['name'],dataVersion:groupVersion,multiSort:true,columns:columns,rowId:(r)=>r.id,collapsibleGroups:true,collapsedGroups:remoteCollapsed,onCollapsedGroupsChanged:(v)=>setState(()=>remoteCollapsed=v),selectedIds:remoteSelected,onSelectionChanged:(v)=>setState(()=>remoteSelected=v),canSelect:(r)=>r.count<100),
  Text('Grouped requests: $groupRequests · $groupQuery'),
  const Text('Remote paging adapter'),
  NgCheckBox(label: 'Fail requests', value: fail, onChanged: (v) => setState(() => fail = v)),
  NgButton(onPressed: () => setState(() => version++), child: const Text('Reload source')),
  NgDataGrid<({String id, String name, int count})>(readData: read, export:exportConfig(true), dataVersion: version, multiSort: true,
    columns: columns, rowId: (r) => r.id, selectedIds: selected, canSelect: (r) => r.count < 100,
    pageSizes: const [5,10,20], onSelectionChanged: (v) => setState(() => selected = v)),
  Text('Requests: $requests · Latest query: $query'),
]);

Blazor parameters and events

Names are case-sensitive. ? permits null. EditorRequired parameters are marked required; other values use the initializer or CLR default.

Parameter Type Default / requirement Purpose
Virtualize bool false Opt-in fixed-height virtual rendering. Local Grid covers the result; remote Grid retains pages; trees cover loaded expanded nodes. See Virtual data.
RowHeight float 48 Fixed virtual row height, default 48 pixels; finite and at least 40. Templates must fit.
ViewportHeight float 384 Virtual scroll viewport height, default 384 pixels; finite and at least one row.
Export NgGridExport<TItem>? not set Optional NgGridExport configuration: query/selection scope, ordered columns, text formatting, bounded CSV/TSV delivery and a full-data remote adapter. See Grid export.
Editing NgGridEditing<TItem>? not set Optional NgGridEditing configuration for isolated drafts, validation, async persistence and confirmed deletion. See Grid editing.
ReadData Func<NgDataRequest,CancellationToken,Task<NgDataResult<TItem>>>? not set Async page adapter; return requested rows and filtered total. See request/result contracts above.
ReadGroupedData Func<NgGroupedDataRequest,CancellationToken,Task<NgGroupedDataResult<TItem>>>? not set Async grouped page adapter; mutually exclusive with ReadData and local grouping/aggregates.
RemoteGroupKeys IReadOnlyList<string> [] Ordered application-defined field identifiers, one to eight distinct nonblank keys. Changes reload page 1.
RemoteSummaryText Func<string,double?,string>? not set Plain-text server summary formatter receiving title and nullable numeric value.
ExpandPageGroupsText string "Expand page groups" Localized remote bulk-expand label; affects current page groups only.
CollapsePageGroupsText string "Collapse page groups" Localized remote bulk-collapse label; affects current page groups only.
GroupBy Func<TItem,string>? not set Pure local string-key selector; grouping precedes paging and cannot be combined with ReadData.
GroupText Func<string,int,string>? not set Plain-text caption from the group key and complete filtered group count.
GroupSelectors IReadOnlyList<Func<TItem,string>> [] Ordered local string selectors, up to eight levels. Mutually exclusive with GroupBy.
GroupAggregates IReadOnlyList<NgGroupAggregate<TItem>> [] Numeric aggregate descriptors over all filtered records in each group.
CollapsibleGroups bool false Enable controlled group expansion and bulk actions. Default false.
CollapsedGroups HashSet<string> [] Parent-owned set of encoded full group path IDs; use GroupId/ngGroupId.
CollapsedGroupsChanged EventCallback<HashSet<string>> not set Propose one fresh collapsed-ID set; accept by storing it.
GroupExpansionReadOnly bool false Lock group expansion independently of row selection.
GroupToggleLabel Func<IReadOnlyList<string>,bool,string>? not set Localized accessible toggle name from path and current expanded state.
ExpandGroupsText string "Expand all groups" Localized expand-all action label.
CollapseGroupsText string "Collapse all groups" Localized collapse-all action label.
DataVersion object? not set Change to invalidate pending reads and cached data; keep stable otherwise.
MultiSort bool false Append sorted columns in priority order; default false.
LoadingText string "Loading records…" Localized loading status.
ErrorText string "Unable to load records." Localized failure status; raw service errors are not rendered.
RetryText string "Retry" Localized retry action text.
Items IReadOnlyList<TItem> [] Input collection. Replace it when changing records or configuration.
Columns IReadOnlyList<NgDataColumn<TItem>> required Column definitions; see the data model and example.
RowId Func<TItem,string> required See the behavior rules and model contract above.
Label string "Records" Visible label or accessible name.
EmptyText string "No matching records." See the behavior rules and model contract above.
CanSelect Func<TItem,bool>? not set See the behavior rules and model contract above.
SelectionReadOnly bool false See the behavior rules and model contract above.
EmptyTemplate RenderFragment? not set See the behavior rules and model contract above.
PageSizes IReadOnlyList<int> [5,10,25,50] See the behavior rules and model contract above.
Selectable bool true See the behavior rules and model contract above.
SelectedIds HashSet<string> [] See the behavior rules and model contract above.
SelectedIdsChanged EventCallback<HashSet<string>> not set See the behavior rules and model contract above.
Toolbar RenderFragment? not set See the behavior rules and model contract above.
RowActions RenderFragment<TItem>? not set See the behavior rules and model contract above.
InitialPageSize int 5 See the behavior rules and model contract above.
ReadRange Func<NgDataRangeRequest,CancellationToken,Task<NgDataRangeResult<TItem>>>? not set Flat remote range adapter; requires Virtualize and excludes Items, page readers and grouping. See Remote ranges.
LoadedWindowText string "Loaded window" Localized label for the retained range and Page export scope.
SelectWindowText string "Select loaded window" Localized accessible name for selection of eligible loaded-window records.

Flutter parameters and events

required marks a mandatory argument even if its type is nullable. key is the standard Flutter Key. The exact constructor below specifies defaults.

Argument Type Purpose
readRange Future<NgDataRangeResult<T>> Function(NgDataRangeRequest)? Flat remote range adapter; requires Virtualize and excludes Items, page readers and grouping. See Remote ranges.
loadedWindowText String Localized label for the retained range and Page export scope.
selectWindowText String Localized accessible name for selection of eligible loaded-window records.
virtualize bool Opt-in fixed-height virtual rendering. Local Grid covers the result; remote Grid retains pages; trees cover loaded expanded nodes. See Virtual data.
rowHeight double Fixed virtual row height, default 48 pixels; finite and at least 40. Templates must fit.
viewportHeight double Virtual scroll viewport height, default 384 pixels; finite and at least one row.
export NgGridExport<T>? Optional NgGridExport configuration: query/selection scope, ordered columns, text formatting, bounded CSV/TSV delivery and a full-data remote adapter. See Grid export.
editing NgGridEditing<T>? Optional NgGridEditing configuration for isolated drafts, validation, async persistence and confirmed deletion. See Grid editing.
items List<T> Input collection. Replace it when changing records or configuration.
readData Future<NgDataResult<T>> Function(NgDataRequest)? Async page adapter; return requested rows and filtered total. See request/result contracts above.
readGroupedData Future<NgGroupedDataResult<T>> Function(NgGroupedDataRequest)? Async grouped page adapter; mutually exclusive with ReadData and local grouping/aggregates.
remoteGroupKeys List<String> Ordered application-defined field identifiers, one to eight distinct nonblank keys. Changes reload page 1.
remoteSummaryText String Function(String, double?)? Plain-text server summary formatter receiving title and nullable numeric value.
expandPageGroupsText String Localized remote bulk-expand label; affects current page groups only.
collapsePageGroupsText String Localized remote bulk-collapse label; affects current page groups only.
dataVersion Object? Change to invalidate pending reads and cached data; keep stable otherwise.
groupBy String Function(T)? Pure local string-key selector; grouping precedes paging and cannot be combined with ReadData.
groupText String Function(String, int)? Plain-text caption from the group key and complete filtered group count.
groupSelectors List<String Function(T)> Ordered local string selectors, up to eight levels. Mutually exclusive with GroupBy.
groupAggregates List<NgGroupAggregate<T>> Numeric aggregate descriptors over all filtered records in each group.
collapsibleGroups bool Enable controlled group expansion and bulk actions. Default false.
groupExpansionReadOnly bool Lock group expansion independently of row selection.
collapsedGroups Set<String> Parent-owned set of encoded full group path IDs; use GroupId/ngGroupId.
onCollapsedGroupsChanged ValueChanged<Set<String>>? Propose one fresh collapsed-ID set; accept with setState.
groupToggleLabel String Function(List<String>, bool)? Localized accessible toggle name from path and current expanded state.
expandGroupsText String Localized expand-all action label.
collapseGroupsText String Localized collapse-all action label.
multiSort bool Append sorted columns in priority order; default false.
loadingText String Localized loading status.
errorText String Localized failure status; raw service errors are not rendered.
retryText String Localized retry action text.
columns List<NgDataColumn<T>> Column definitions; see the data model and example.
rowId String Function(T) See the behavior rules and model contract above.
selectedIds Set<String> See the behavior rules and model contract above.
onSelectionChanged ValueChanged<Set<String>>? See the behavior rules and model contract above.
rowActions Widget Function(BuildContext, T)? See the behavior rules and model contract above.
toolbar Widget? See the behavior rules and model contract above.
emptyBuilder Widget? See the behavior rules and model contract above.
selectionReadOnly bool See the behavior rules and model contract above.
canSelect bool Function(T)? See the behavior rules and model contract above.
pageSizes List<int> See the behavior rules and model contract above.
label String Visible label or accessible name.
emptyText String See the behavior rules and model contract above.
initialPageSize int See the behavior rules and model contract above.
Dart / Flutter
const NgDataGrid({
    super.key,
    this.readRange,
    this.loadedWindowText = 'Loaded window',
    this.selectWindowText = 'Select loaded window',
    this.virtualize = false,
    this.rowHeight = 48,
    this.viewportHeight = 384,
    this.items = const [],
    this.editing,
    this.export,
    this.readData,
    this.readGroupedData,
    this.remoteGroupKeys = const [],
    this.remoteSummaryText,
    this.expandPageGroupsText = 'Expand page groups',
    this.collapsePageGroupsText = 'Collapse page groups',
    this.dataVersion,
    this.groupBy,
    this.groupText,
    this.groupSelectors = const [],
    this.groupAggregates = const [],
    this.collapsibleGroups = false,
    this.groupExpansionReadOnly = false,
    this.collapsedGroups = const {},
    this.onCollapsedGroupsChanged,
    this.groupToggleLabel,
    this.expandGroupsText = 'Expand all groups',
    this.collapseGroupsText = 'Collapse all groups',
    this.multiSort = false,
    this.loadingText = 'Loading records…',
    this.errorText = 'Unable to load records.',
    this.retryText = 'Retry',
    required this.columns,
    required this.rowId,
    this.selectedIds = const {},
    this.onSelectionChanged,
    this.rowActions,
    this.toolbar,
    this.emptyBuilder,
    this.selectionReadOnly = false,
    this.canSelect,
    this.pageSizes = const [5, 10, 25, 50],
    this.label = 'Records',
    this.emptyText = 'No matching records.',
    this.initialPageSize = 5,
  });

Integration and troubleshooting

If the UI does not update, confirm that the parent stores callback values and passes them back. Blazor event handlers need interactive rendering; Flutter requires setState or a state manager. Submit form data only after successful validation.

Check accessible labels, empty/invalid states, disabled actions and narrow layouts. Keyboard support is not full screen-reader certification. Current visual verification targets Web in visible Chrome on Linux/iMac; native mobile platforms are a separate phase.

Source and complete examples

API declarations are generated from current source. Curated examples and behavior live in scripts/manual_content.py; regenerate with python3 scripts/generate_manual.py.