diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 332a4f7..d09aaa7 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -112,6 +112,7 @@ Same names, properties and variants as MAUI, except:
| Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` |
| Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) |
| Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) |
+| DataTable | Tap a header to sort | Sortable headers are also tab stops (Enter / Space) and keep focus after sorting; a click on a row's checkbox does not raise `RowTapped` |
| Command | Enter picks the highlighted item (the first match) | Up / Down move the highlight and Enter picks it; `Value`, `Text` and the other strings are nullable; children are collected in `Groups` and `Items` |
| CommandPalette | Open it from code | Also `Hotkey`, a key gesture that toggles it anywhere in the window (Ctrl+K by default; `Hotkey="{x:Null}"` for none) |
| NavigationMenu | Tap an item to open its panel | Items are tab stops: Enter / Space / Down open a panel and Left / Right move between items; in a panel Up / Down move and Enter picks; children are collected in `Items` and `Links` |
@@ -1194,6 +1195,48 @@ Use star and fixed widths in `Columns`: every row is its own grid, so an `Auto`
per row. For long lists, put a `TableHeader` above a `CollectionView` whose item template is a
`TableRow` with the same `Columns` set on the row itself.
+### Data Table
+A `Table` filled from a list of objects, with a filter field, sortable column headers, optional
+row selection and pages. Each column names the item property it shows.
+
+```bash
+shellui-native add data-table
+```
+
+**Properties (DataTable):** `ItemsSource` (any list; an `ObservableCollection` refreshes the
+table as it changes), `ShowFilter` (true), `FilterPlaceholder`, `Filter` (the filter text),
+`ShowPagination` (true), `PageSize` (10), `ShowSelection` (false: adds a checkbox column),
+`EmptyText` ("No results."), `MinimumContentWidth`, `SelectedItems`, `Page`, `PageCount`
+(read-only) · **Events:** `SelectionChanged` (the selected items), `RowTapped` (the row's item)
+· **Methods:** `Refresh()`, `GoToPage(int)`, `SortBy(column, descending)`, `ClearSelection()`
+
+**Properties (DataTableColumn):** `Header`, `Property` (the item property to show), `Format` (a
+.NET format string such as `C` or `MMM d, yyyy`), `Width` (`*`, `2*` or a fixed number),
+`IsSortable` (true), `IsBold`, `HorizontalTextAlignment`, `Value` (a function that reads the
+value; set in code, replaces `Property`)
+
+```xml
+
+
+
+
+
+```
+
+The filter matches every typed word against the text of any column. A sortable header cycles
+ascending, descending and unsorted; numbers and dates sort by value. The header checkbox selects
+every row that passes the filter, across pages. Cells show text only: for badges or buttons in
+cells, build the rows yourself with `Table`.
+
+`Property` is read by name at run time. In an app published with trimming or NativeAOT, set
+`Value` in code instead, so the property is not trimmed away:
+
+```csharp
+InvoiceTable.Columns[0].Value = item => ((Invoice)item).Number;
+```
+
+---
+
### Carousel
One slide at a time in a rounded frame. Swipe, use the round arrow buttons or tap a dot to move.
Each child view is a slide.
diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md
index 6f0883c..1e321c5 100644
--- a/docs/COMPONENTS_ROADMAP.md
+++ b/docs/COMPONENTS_ROADMAP.md
@@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell
Last revised: **2026-10-11** (second prerelease, `0.1.0-alpha.2`).
-**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu.
+**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table.
---
@@ -295,7 +295,9 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms
**After `0.1.0-alpha.2`, from ShellUI's Blazor set** (both platforms): button-group ✅,
input-group ✅ and menubar ✅ (2026-10-11), then command ✅ (with a command palette) and
-navigation-menu ✅ (2026-10-12; 68 families, 99 CLI targets).
+navigation-menu ✅, then data-table ✅ (client-side filter, sort, selection and pages;
+2026-10-12; 69 families, 100 CLI targets).
-**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (data-table, chat,
-form, image-viewer, qr-code, …).
+**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (chat, form,
+image-viewer, qr-code, …). Not in the native data-table yet: row actions and server-side
+paging.
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index 37a074a..90d3d5c 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -19,8 +19,8 @@ into it, not directly into `main`.
main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avalonia (PRs #7–#13),
layout, navigation and pickers on both platforms (PR #14) merged
release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16), button-group,
- input-group and menubar (PR #17) merged
- └─ feat/command-navigation-menu ← second batch from ShellUI's Blazor set (active)
+ input-group and menubar (PR #17), command and navigation-menu (PR #18) merged
+ └─ feat/data-table ← data-table from ShellUI's Blazor set (active)
└─ feat/winui ← Phase 3 (conditional)
```
@@ -37,8 +37,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni
| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
-| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PR #17 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content |
-| `feat/command-navigation-menu` | `main` | **Active** | command (with a command palette) and navigation-menu for MAUI and Avalonia (102 registry entries, 99 CLI targets, 68 families) |
+| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17 and #18 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content |
+| `feat/data-table` | `main` | **Active** | data-table for MAUI and Avalonia (103 registry entries, 100 CLI targets, 69 families) |
---
@@ -529,6 +529,9 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
CommandGroup, CommandItem, CommandSeparator and CommandPalette, which hosts a Command in a
`ShellOverlayHost`; Avalonia adds a window-wide hotkey) and navigation-menu (NavigationMenu,
NavigationMenuItem, NavigationMenuLink)
+- [x] data-table (`feat/data-table`): DataTable + DataTableColumn on top of `table`, `input`,
+ `checkbox` and `button`; columns read item properties by name (reflection, with a `Value`
+ function for trimmed apps); no row actions or server-side paging yet
- [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages
once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so
components change little: token tinting binds the package's `Color` (MAUI) or `Foreground`
diff --git a/examples/Avalonia.Demo/Components/UI/DataTable.cs b/examples/Avalonia.Demo/Components/UI/DataTable.cs
new file mode 100644
index 0000000..41145d3
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DataTable.cs
@@ -0,0 +1,467 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using AvaloniaDemo.Components.UI.Variants;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Data table — a Table filled from a list of objects, with a filter field, sortable column
+// headers, optional row selection and pages. Sortable headers are tab stops (Enter / Space).
+//
+//
+//
+//
+//
+// A column reads its Property from each item by name. With trimming or NativeAOT, set the
+// column's Value in code instead: column.Value = item => ((Invoice)item).Number;
+public class DataTable : Border
+{
+ public static readonly StyledProperty ItemsSourceProperty =
+ AvaloniaProperty.Register(nameof(ItemsSource));
+
+ public static readonly StyledProperty ShowFilterProperty =
+ AvaloniaProperty.Register(nameof(ShowFilter), true);
+
+ public static readonly StyledProperty FilterPlaceholderProperty =
+ AvaloniaProperty.Register(nameof(FilterPlaceholder), "Filter...");
+
+ public static readonly StyledProperty ShowPaginationProperty =
+ AvaloniaProperty.Register(nameof(ShowPagination), true);
+
+ // Rows per page while ShowPagination is on.
+ public static readonly StyledProperty PageSizeProperty =
+ AvaloniaProperty.Register(nameof(PageSize), 10);
+
+ // Adds a checkbox column; the header checkbox selects every row that passes the filter.
+ public static readonly StyledProperty ShowSelectionProperty =
+ AvaloniaProperty.Register(nameof(ShowSelection));
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), "No results.");
+
+ // Below this width the table scrolls sideways instead of squeezing its columns. 0 never scrolls.
+ public static readonly StyledProperty MinimumContentWidthProperty =
+ AvaloniaProperty.Register(nameof(MinimumContentWidth));
+
+ private readonly HashSet _selected = new();
+ private readonly List<(object Item, TableRow Row, Checkbox? Check)> _rowViews = new();
+ private readonly Input _filter;
+ private readonly Table _table = new();
+ private readonly Grid _footer;
+ private readonly TextBlock _summary;
+ private readonly TextBlock _pageLabel;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private Checkbox? _allCheck;
+ private List _view = new();
+ private DataTableColumn? _sortColumn;
+ private DataTableColumn? _refocus;
+ private bool _descending;
+ private bool _rendering;
+
+ static DataTable()
+ {
+ ItemsSourceProperty.Changed.AddClassHandler((t, e) => t.OnItemsSourceChanged(e.OldValue, e.NewValue));
+ ShowFilterProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ FilterPlaceholderProperty.Changed.AddClassHandler((t, e) => t._filter.Placeholder = (string?)e.NewValue);
+ ShowPaginationProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ PageSizeProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ ShowSelectionProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ EmptyTextProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ MinimumContentWidthProperty.Changed.AddClassHandler((t, e) => t._table.MinimumContentWidth = (double)e.NewValue!);
+ }
+
+ public IEnumerable? ItemsSource
+ {
+ get => GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public bool ShowFilter
+ {
+ get => GetValue(ShowFilterProperty);
+ set => SetValue(ShowFilterProperty, value);
+ }
+
+ public string? FilterPlaceholder
+ {
+ get => GetValue(FilterPlaceholderProperty);
+ set => SetValue(FilterPlaceholderProperty, value);
+ }
+
+ public bool ShowPagination
+ {
+ get => GetValue(ShowPaginationProperty);
+ set => SetValue(ShowPaginationProperty, value);
+ }
+
+ public int PageSize
+ {
+ get => GetValue(PageSizeProperty);
+ set => SetValue(PageSizeProperty, value);
+ }
+
+ public bool ShowSelection
+ {
+ get => GetValue(ShowSelectionProperty);
+ set => SetValue(ShowSelectionProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ [Content]
+ public AvaloniaList Columns { get; } = new();
+
+ // The selected items, in the order of ItemsSource.
+ public IReadOnlyList SelectedItems => Source().Where(_selected.Contains).ToList();
+
+ // The page shown, from 1.
+ public int Page { get; private set; } = 1;
+
+ public int PageCount { get; private set; } = 1;
+
+ // The text in the filter field.
+ public string Filter
+ {
+ get => _filter.Text ?? string.Empty;
+ set => _filter.Text = value;
+ }
+
+ public event EventHandler>? SelectionChanged;
+
+ // Raised with the row's item.
+ public event EventHandler? RowTapped;
+
+ public DataTable()
+ {
+ _filter = new Input { Placeholder = FilterPlaceholder, MaxWidth = 384, MinWidth = 240, HorizontalAlignment = HorizontalAlignment.Left };
+ _filter.TextChanged += (_, _) => { Page = 1; Refresh(); };
+
+ _summary = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _summary.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _pageLabel = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) };
+ _pageLabel.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _previous = new Button { Text = "Previous", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _previous.Clicked += (_, _) => GoToPage(Page - 1);
+ _next = new Button { Text = "Next", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _next.Clicked += (_, _) => GoToPage(Page + 1);
+
+ var pager = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center, Children = { _pageLabel, _previous, _next } };
+ Grid.SetColumn(pager, 1);
+ _footer = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 12, Children = { _summary, pager } };
+
+ Child = new StackPanel { Spacing = 12, Children = { _filter, _table, _footer } };
+ Columns.CollectionChanged += (_, _) => Refresh();
+ Refresh();
+ }
+
+ // Re-reads the items and columns. Call it after changing an item's values or a column.
+ public void Refresh()
+ {
+ // The table and its parts don't exist yet while property defaults are applied.
+ if (_footer is null) return;
+ var items = Source();
+ _selected.IntersectWith(items);
+
+ var words = Filter.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ IEnumerable view = ShowFilter && words.Length > 0
+ ? items.Where(item => words.All(word => Columns.Any(c => c.Text(item).Contains(word, StringComparison.CurrentCultureIgnoreCase))))
+ : items;
+ if (_sortColumn != null && Columns.Contains(_sortColumn))
+ {
+ var column = _sortColumn;
+ view = _descending
+ ? view.OrderByDescending(item => column.Read(item), ValueComparer.Instance)
+ : view.OrderBy(item => column.Read(item), ValueComparer.Instance);
+ }
+ _view = view.ToList();
+
+ var size = Math.Max(1, PageSize);
+ PageCount = ShowPagination ? Math.Max(1, (int)Math.Ceiling(_view.Count / (double)size)) : 1;
+ Page = Math.Clamp(Page, 1, PageCount);
+ Render(ShowPagination ? _view.Skip((Page - 1) * size).Take(size).ToList() : _view);
+ }
+
+ public void GoToPage(int page)
+ {
+ Page = page;
+ Refresh();
+ }
+
+ // Sorts by a column; call it again to reverse, and with null to restore the source order.
+ public void SortBy(DataTableColumn? column, bool descending = false)
+ {
+ _sortColumn = column;
+ _descending = descending;
+ Refresh();
+ }
+
+ public void ClearSelection()
+ {
+ if (_selected.Count == 0) return;
+ _selected.Clear();
+ OnSelectionChanged();
+ }
+
+ private List Source() => ItemsSource?.Cast().Where(item => item != null).ToList() ?? new List();
+
+ private void OnItemsSourceChanged(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnItemsChanged;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnItemsChanged;
+ Page = 1;
+ Refresh();
+ }
+
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Refresh();
+
+ private void Render(IReadOnlyList rows)
+ {
+ _rendering = true;
+ _filter.IsVisible = ShowFilter;
+ var widths = Columns.Select(c => string.IsNullOrWhiteSpace(c.Width) ? "*" : c.Width);
+ _table.Columns = string.Join(",", ShowSelection ? widths.Prepend("48") : widths);
+ _table.Rows.Clear();
+ _rowViews.Clear();
+ _allCheck = null;
+
+ var header = new TableHeader();
+ if (ShowSelection)
+ {
+ var all = new Checkbox { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ AutomationProperties.SetName(all, "Select all rows");
+ all.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.UnionWith(_view);
+ else _selected.ExceptWith(_view);
+ OnSelectionChanged();
+ };
+ header.Children.Add(all);
+ _allCheck = all;
+ }
+ foreach (var column in Columns) header.Children.Add(CreateHead(column));
+ _table.Rows.Add(header);
+
+ foreach (var item in rows)
+ {
+ var row = new TableRow();
+ // A click on the row's checkbox selects; it is not a click on the row.
+ row.Tapped += (_, e) =>
+ {
+ if (e.Source is Visual source && source.FindAncestorOfType(includeSelf: true) != null) return;
+ RowTapped?.Invoke(this, item);
+ };
+ Checkbox? check = null;
+ if (ShowSelection)
+ {
+ check = new Checkbox { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ AutomationProperties.SetName(check, "Select row");
+ check.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.Add(item);
+ else _selected.Remove(item);
+ OnSelectionChanged();
+ };
+ row.Children.Add(check);
+ }
+ _rowViews.Add((item, row, check));
+ foreach (var column in Columns)
+ row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment });
+ _table.Rows.Add(row);
+ }
+
+ if (rows.Count == 0)
+ {
+ // h-24 text-center, one cell across the table.
+ var empty = new TableHeader { Columns = "*" };
+ empty.Children.Add(new TableCell { Text = EmptyText ?? string.Empty, HorizontalTextAlignment = TextAlignment.Center, Height = 96 });
+ _table.Rows.Add(empty);
+ }
+
+ _footer.IsVisible = ShowPagination || ShowSelection;
+ ShowSelectionState();
+ _pageLabel.Text = $"Page {Page} of {PageCount}";
+ _pageLabel.IsVisible = _previous.IsVisible = _next.IsVisible = ShowPagination;
+ _previous.IsEnabled = Page > 1;
+ _next.IsEnabled = Page < PageCount;
+ _rendering = false;
+ }
+
+ // Ticks, row tints and the count follow the selection without rebuilding the rows.
+ private void ShowSelectionState()
+ {
+ var rendering = _rendering;
+ _rendering = true;
+ foreach (var (item, row, check) in _rowViews)
+ {
+ var selected = _selected.Contains(item);
+ row.IsSelected = selected;
+ if (check != null) check.IsChecked = selected;
+ }
+ if (_allCheck != null) _allCheck.IsChecked = _view.Count > 0 && _view.All(_selected.Contains);
+ _summary.Text = ShowSelection
+ ? $"{_view.Count(_selected.Contains)} of {_view.Count} row(s) selected."
+ : $"{_view.Count} row(s).";
+ _rendering = rendering;
+ }
+
+ private void OnSelectionChanged()
+ {
+ ShowSelectionState();
+ SelectionChanged?.Invoke(this, SelectedItems);
+ }
+
+ // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction
+ // and cycles ascending, descending, unsorted on click.
+ private Control CreateHead(DataTableColumn column)
+ {
+ var label = new TextBlock
+ {
+ Text = column.Header ?? string.Empty,
+ FontSize = 14,
+ FontWeight = FontWeight.Medium,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ var row = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 6,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = column.HorizontalTextAlignment switch
+ {
+ TextAlignment.End or TextAlignment.Right => HorizontalAlignment.Right,
+ TextAlignment.Center => HorizontalAlignment.Center,
+ _ => HorizontalAlignment.Left
+ },
+ Children = { label }
+ };
+ var head = new Border { Child = row, Height = 44, Padding = new Thickness(16, 0) };
+ if (!column.IsSortable) return head;
+
+ var sorted = ReferenceEquals(_sortColumn, column);
+ row.Children.Add(new Icon
+ {
+ Kind = !sorted ? IconName.ChevronsUpDown : _descending ? IconName.ArrowDown : IconName.ArrowUp,
+ Size = 14,
+ Token = sorted ? ShellToken.Foreground : ShellToken.MutedForeground,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ if (sorted) label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ AutomationProperties.SetName(head, $"Sort by {column.Header}");
+ // Transparent, not null, so the whole header cell takes the pointer.
+ head.Background = Brushes.Transparent;
+ head.Cursor = new Cursor(StandardCursorType.Hand);
+ head.CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ ShellFocus.Ring(head, head);
+ // Sorting rebuilds the header row, so the rebuild waits until this event is done.
+ head.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Dispatcher.UIThread.Post(() => ToggleSort(column));
+ e.Handled = true;
+ };
+ head.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ // Keep keyboard focus on this column's new header cell.
+ _refocus = column;
+ Dispatcher.UIThread.Post(() => ToggleSort(column));
+ e.Handled = true;
+ };
+ if (ReferenceEquals(_refocus, column))
+ {
+ _refocus = null;
+ Dispatcher.UIThread.Post(() => head.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
+ }
+ return head;
+ }
+
+ private void ToggleSort(DataTableColumn column)
+ {
+ if (!ReferenceEquals(_sortColumn, column)) SortBy(column);
+ else if (!_descending) SortBy(column, descending: true);
+ else SortBy(null);
+ }
+
+ // Numbers and dates sort by value, everything else by its text; nulls go first.
+ private sealed class ValueComparer : IComparer
+ {
+ public static readonly ValueComparer Instance = new();
+
+ public int Compare(object? x, object? y)
+ {
+ if (x is null || y is null) return x is null ? (y is null ? 0 : -1) : 1;
+ if (x is string || y is string || x.GetType() != y.GetType() || x is not IComparable comparable)
+ return string.Compare(Convert.ToString(x, CultureInfo.CurrentCulture), Convert.ToString(y, CultureInfo.CurrentCulture), StringComparison.CurrentCultureIgnoreCase);
+ return comparable.CompareTo(y);
+ }
+ }
+}
+
+// One column of a DataTable.
+public class DataTableColumn
+{
+ public string? Header { get; set; }
+
+ // The name of the item property this column shows.
+ public string? Property { get; set; }
+
+ // Reads the column's value from an item; replaces Property.
+ public Func? Value { get; set; }
+
+ // A .NET format string for the value, e.g. "C" or "MMM d, yyyy".
+ public string? Format { get; set; }
+
+ // A Grid column width: "*", "2*" or a fixed number. Auto is not supported.
+ public string Width { get; set; } = "*";
+
+ public bool IsSortable { get; set; } = true;
+
+ public bool IsBold { get; set; }
+
+ public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
+
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Property is looked up by name on the item's runtime type; set Value instead when trimming.")]
+ internal object? Read(object item)
+ {
+ if (Value != null) return Value(item);
+ return string.IsNullOrEmpty(Property) ? null : item.GetType().GetProperty(Property)?.GetValue(item);
+ }
+
+ internal string Text(object item)
+ {
+ var value = Read(item);
+ if (value is null) return string.Empty;
+ return !string.IsNullOrEmpty(Format) && value is IFormattable formattable
+ ? formattable.ToString(Format, CultureInfo.CurrentCulture)
+ : Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
+ }
+}
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index 8b4b689..bfdf140 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -672,6 +672,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 1686f8f..6246c1e 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
@@ -84,6 +85,24 @@ public MainWindow()
DemoCommand.ItemSelected += (_, item) => CommandChoice.Text = $"Command: {item.Value}.";
DemoPalette.ItemSelected += (_, item) => CommandChoice.Text = $"Palette: {item.Value}.";
OpenPalette.Clicked += (_, _) => DemoPalette.SetOpen(true);
+ DemoDataTable.ItemsSource = new List
+ {
+ new DemoInvoice("INV001", "Shewart Shepherd", "Paid", 250.00m, new DateTime(2026, 9, 2)),
+ new DemoInvoice("INV002", "ShellUI", "Pending", 150.00m, new DateTime(2026, 9, 5)),
+ new DemoInvoice("INV003", "ShellIcons", "Unpaid", 350.00m, new DateTime(2026, 9, 9)),
+ new DemoInvoice("INV004", "ShellDocs", "Paid", 450.00m, new DateTime(2026, 9, 12)),
+ new DemoInvoice("INV005", "Shewart Shepherd", "Paid", 550.00m, new DateTime(2026, 9, 14)),
+ new DemoInvoice("INV006", "ShellUI Native", "Pending", 200.00m, new DateTime(2026, 9, 18)),
+ new DemoInvoice("INV007", "ShellUI", "Unpaid", 300.00m, new DateTime(2026, 9, 21)),
+ new DemoInvoice("INV008", "ShellDocs", "Paid", 75.50m, new DateTime(2026, 9, 24)),
+ new DemoInvoice("INV009", "ShellIcons", "Paid", 1200.00m, new DateTime(2026, 9, 27)),
+ new DemoInvoice("INV010", "ShellUI Native", "Pending", 89.99m, new DateTime(2026, 10, 1)),
+ new DemoInvoice("INV011", "Shewart Shepherd", "Unpaid", 640.00m, new DateTime(2026, 10, 4)),
+ new DemoInvoice("INV012", "ShellUI", "Paid", 310.25m, new DateTime(2026, 10, 8)),
+ };
+ DemoDataTable.SelectionChanged += (_, items) =>
+ DataTableChoice.Text = $"Data table: {items.Count} selected ({string.Join(", ", items.Cast().Select(i => i.Number))}).";
+ DemoDataTable.RowTapped += (_, item) => DataTableChoice.Text = $"Data table row: {((DemoInvoice)item).Number}.";
DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}.";
DemoSidebar.SelectedValueChanged += (_, value) =>
{
@@ -139,3 +158,5 @@ public MainWindow()
}
}
}
+
+public sealed record DemoInvoice(string Number, string Customer, string Status, decimal Amount, DateTime Date);
diff --git a/examples/MAUI.Demo/Components/UI/DataTable.cs b/examples/MAUI.Demo/Components/UI/DataTable.cs
new file mode 100644
index 0000000..4c7be94
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/DataTable.cs
@@ -0,0 +1,433 @@
+using System.Collections;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using MAUI.Demo.Components.UI.Variants;
+
+namespace MAUI.Demo.Components.UI;
+
+// Data table — a Table filled from a list of objects, with a filter field, sortable column
+// headers, optional row selection and pages.
+//
+//
+//
+//
+//
+// A column reads its Property from each item by name. With trimming or NativeAOT, set the
+// column's Value in code instead: column.Value = item => ((Invoice)item).Number;
+[ContentProperty(nameof(Columns))]
+public partial class DataTable : ContentView
+{
+ public static readonly BindableProperty ItemsSourceProperty =
+ BindableProperty.Create(nameof(ItemsSource), typeof(IEnumerable), typeof(DataTable), null,
+ propertyChanged: (b, o, n) => ((DataTable)b).OnItemsSourceChanged(o, n));
+
+ public static readonly BindableProperty ShowFilterProperty =
+ BindableProperty.Create(nameof(ShowFilter), typeof(bool), typeof(DataTable), true,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ public static readonly BindableProperty FilterPlaceholderProperty =
+ BindableProperty.Create(nameof(FilterPlaceholder), typeof(string), typeof(DataTable), "Filter...",
+ propertyChanged: (b, o, n) => ((DataTable)b)._filter.Placeholder = n as string ?? string.Empty);
+
+ public static readonly BindableProperty ShowPaginationProperty =
+ BindableProperty.Create(nameof(ShowPagination), typeof(bool), typeof(DataTable), true,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ // Rows per page while ShowPagination is on.
+ public static readonly BindableProperty PageSizeProperty =
+ BindableProperty.Create(nameof(PageSize), typeof(int), typeof(DataTable), 10,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ // Adds a checkbox column; the header checkbox selects every row that passes the filter.
+ public static readonly BindableProperty ShowSelectionProperty =
+ BindableProperty.Create(nameof(ShowSelection), typeof(bool), typeof(DataTable), false,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(DataTable), "No results.",
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ // Below this width the table scrolls sideways instead of squeezing its columns. 0 never scrolls.
+ public static readonly BindableProperty MinimumContentWidthProperty =
+ BindableProperty.Create(nameof(MinimumContentWidth), typeof(double), typeof(DataTable), 0d,
+ propertyChanged: (b, o, n) => ((DataTable)b)._table.MinimumContentWidth = (double)n);
+
+ public IEnumerable? ItemsSource
+ {
+ get => (IEnumerable?)GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public bool ShowFilter
+ {
+ get => (bool)GetValue(ShowFilterProperty);
+ set => SetValue(ShowFilterProperty, value);
+ }
+
+ public string FilterPlaceholder
+ {
+ get => (string)GetValue(FilterPlaceholderProperty);
+ set => SetValue(FilterPlaceholderProperty, value);
+ }
+
+ public bool ShowPagination
+ {
+ get => (bool)GetValue(ShowPaginationProperty);
+ set => SetValue(ShowPaginationProperty, value);
+ }
+
+ public int PageSize
+ {
+ get => (int)GetValue(PageSizeProperty);
+ set => SetValue(PageSizeProperty, value);
+ }
+
+ public bool ShowSelection
+ {
+ get => (bool)GetValue(ShowSelectionProperty);
+ set => SetValue(ShowSelectionProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => (double)GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ public IList Columns => _columns;
+
+ // The selected items, in the order of ItemsSource.
+ public IReadOnlyList SelectedItems => Source().Where(_selected.Contains).ToList();
+
+ // The page shown, from 1.
+ public int Page { get; private set; } = 1;
+
+ public int PageCount { get; private set; } = 1;
+
+ // The text in the filter field.
+ public string Filter
+ {
+ get => _filter.Text ?? string.Empty;
+ set => _filter.Text = value;
+ }
+
+ public event EventHandler>? SelectionChanged;
+
+ // Raised with the row's item.
+ public event EventHandler? RowTapped;
+
+ private readonly ObservableCollection _columns = new();
+ private readonly HashSet _selected = new();
+ private readonly Input _filter;
+ private readonly Table _table;
+ private readonly Grid _footer;
+ private readonly Label _summary;
+ private readonly Label _pageLabel;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private readonly List<(object Item, TableRow Row, Checkbox? Check)> _rowViews = new();
+ private Checkbox? _allCheck;
+ private List _view = new();
+ private DataTableColumn? _sortColumn;
+ private bool _descending;
+ private bool _rendering;
+
+ public DataTable()
+ {
+ _filter = new Input { Placeholder = FilterPlaceholder, MaximumWidthRequest = 384, HorizontalOptions = LayoutOptions.Start, MinimumWidthRequest = 240 };
+ _filter.TextChanged += (_, _) => { Page = 1; Refresh(); };
+
+ _table = new Table();
+
+ _summary = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _summary.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _pageLabel = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(0, 0, 4, 0) };
+ _pageLabel.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _previous = new Button { Text = "Previous", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _previous.Clicked += (_, _) => GoToPage(Page - 1);
+ _next = new Button { Text = "Next", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _next.Clicked += (_, _) => GoToPage(Page + 1);
+
+ var pager = new HorizontalStackLayout { Spacing = 8, VerticalOptions = LayoutOptions.Center, Children = { _pageLabel, _previous, _next } };
+ _footer = new Grid
+ {
+ ColumnSpacing = 12,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) }
+ };
+ _footer.Add(_summary, 0, 0);
+ _footer.Add(pager, 1, 0);
+
+ Content = new VerticalStackLayout { Spacing = 12, Children = { _filter, _table, _footer } };
+ _columns.CollectionChanged += (_, _) => Refresh();
+ Refresh();
+ }
+
+ // Re-reads the items and columns. Call it after changing an item's values or a column.
+ public void Refresh()
+ {
+ // The table and its parts don't exist yet while the constructor sets property defaults.
+ if (_footer is null) return;
+ var items = Source();
+ _selected.IntersectWith(items);
+
+ var words = Filter.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ IEnumerable view = ShowFilter && words.Length > 0
+ ? items.Where(item => words.All(word => _columns.Any(c => c.Text(item).Contains(word, StringComparison.CurrentCultureIgnoreCase))))
+ : items;
+ if (_sortColumn != null && _columns.Contains(_sortColumn))
+ {
+ var column = _sortColumn;
+ view = _descending
+ ? view.OrderByDescending(item => column.Read(item), ValueComparer.Instance)
+ : view.OrderBy(item => column.Read(item), ValueComparer.Instance);
+ }
+ _view = view.ToList();
+
+ var size = Math.Max(1, PageSize);
+ PageCount = ShowPagination ? Math.Max(1, (int)Math.Ceiling(_view.Count / (double)size)) : 1;
+ Page = Math.Clamp(Page, 1, PageCount);
+ Render(ShowPagination ? _view.Skip((Page - 1) * size).Take(size).ToList() : _view);
+ }
+
+ public void GoToPage(int page)
+ {
+ Page = page;
+ Refresh();
+ }
+
+ // Sorts by a column; call it again to reverse, and with null to restore the source order.
+ public void SortBy(DataTableColumn? column, bool descending = false)
+ {
+ _sortColumn = column;
+ _descending = descending;
+ Refresh();
+ }
+
+ public void ClearSelection()
+ {
+ if (_selected.Count == 0) return;
+ _selected.Clear();
+ OnSelectionChanged();
+ }
+
+ private List Source() => ItemsSource?.Cast().Where(item => item != null).ToList() ?? new List();
+
+ private void OnItemsSourceChanged(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnItemsChanged;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnItemsChanged;
+ Page = 1;
+ Refresh();
+ }
+
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Refresh();
+
+ private void Render(IReadOnlyList rows)
+ {
+ _rendering = true;
+ _filter.IsVisible = ShowFilter;
+ var widths = _columns.Select(c => string.IsNullOrWhiteSpace(c.Width) ? "*" : c.Width);
+ _table.Columns = string.Join(",", ShowSelection ? widths.Prepend("48") : widths);
+ _table.Rows.Clear();
+ _rowViews.Clear();
+ _allCheck = null;
+
+ var header = new TableHeader();
+ if (ShowSelection)
+ {
+ var all = new Checkbox { HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ SemanticProperties.SetDescription(all, "Select all rows");
+ all.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.UnionWith(_view);
+ else _selected.ExceptWith(_view);
+ OnSelectionChanged();
+ };
+ header.Children.Add(all);
+ _allCheck = all;
+ }
+ foreach (var column in _columns) header.Children.Add(CreateHead(column));
+ _table.Rows.Add(header);
+
+ foreach (var item in rows)
+ {
+ var row = new TableRow();
+ row.Tapped += (_, _) => RowTapped?.Invoke(this, item);
+ Checkbox? check = null;
+ if (ShowSelection)
+ {
+ check = new Checkbox { HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ SemanticProperties.SetDescription(check, "Select row");
+ check.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.Add(item);
+ else _selected.Remove(item);
+ OnSelectionChanged();
+ };
+ row.Children.Add(check);
+ }
+ _rowViews.Add((item, row, check));
+ foreach (var column in _columns)
+ row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment });
+ _table.Rows.Add(row);
+ }
+
+ if (rows.Count == 0)
+ {
+ // h-24 text-center, one cell across the table.
+ var empty = new TableHeader { Columns = "*" };
+ empty.Children.Add(new TableCell { Text = EmptyText ?? string.Empty, HorizontalTextAlignment = TextAlignment.Center, HeightRequest = 96 });
+ _table.Rows.Add(empty);
+ }
+
+ _footer.IsVisible = ShowPagination || ShowSelection;
+ ShowSelectionState();
+ _pageLabel.Text = $"Page {Page} of {PageCount}";
+ _pageLabel.IsVisible = _previous.IsVisible = _next.IsVisible = ShowPagination;
+ _previous.IsEnabled = Page > 1;
+ _next.IsEnabled = Page < PageCount;
+ _rendering = false;
+ }
+
+ // Ticks, row tints and the count follow the selection without rebuilding the rows.
+ private void ShowSelectionState()
+ {
+ var rendering = _rendering;
+ _rendering = true;
+ foreach (var (item, row, check) in _rowViews)
+ {
+ var selected = _selected.Contains(item);
+ row.IsSelected = selected;
+ if (check != null) check.IsChecked = selected;
+ }
+ if (_allCheck != null) _allCheck.IsChecked = _view.Count > 0 && _view.All(_selected.Contains);
+ _summary.Text = ShowSelection
+ ? $"{_view.Count(_selected.Contains)} of {_view.Count} row(s) selected."
+ : $"{_view.Count} row(s).";
+ _rendering = rendering;
+ }
+
+ private void OnSelectionChanged()
+ {
+ ShowSelectionState();
+ SelectionChanged?.Invoke(this, SelectedItems);
+ }
+
+ // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction
+ // and cycles ascending, descending, unsorted on tap.
+ private View CreateHead(DataTableColumn column)
+ {
+ var label = new Label
+ {
+ Text = column.Header ?? string.Empty,
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ label.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ var row = new HorizontalStackLayout
+ {
+ Spacing = 6,
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = column.HorizontalTextAlignment switch
+ {
+ TextAlignment.End => LayoutOptions.End,
+ TextAlignment.Center => LayoutOptions.Center,
+ _ => LayoutOptions.Start
+ },
+ Children = { label }
+ };
+ var head = new ContentView { Content = row, HeightRequest = 44, Padding = new Thickness(16, 0) };
+ if (!column.IsSortable) return head;
+
+ var sorted = ReferenceEquals(_sortColumn, column);
+ row.Children.Add(new Icon
+ {
+ Name = !sorted ? IconName.ChevronsUpDown : _descending ? IconName.ArrowDown : IconName.ArrowUp,
+ Size = 14,
+ Token = sorted ? ShellToken.Foreground : ShellToken.MutedForeground,
+ VerticalOptions = LayoutOptions.Center
+ });
+ if (sorted) label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ SemanticProperties.SetDescription(head, $"Sort by {column.Header}");
+ // A layout with no background only takes pointer input over its children.
+ head.BackgroundColor = Colors.Transparent;
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Dispatcher.Dispatch(() => ToggleSort(column));
+ head.GestureRecognizers.Add(tap);
+ return head;
+ }
+
+ private void ToggleSort(DataTableColumn column)
+ {
+ if (!ReferenceEquals(_sortColumn, column)) SortBy(column);
+ else if (!_descending) SortBy(column, descending: true);
+ else SortBy(null);
+ }
+
+ // Numbers and dates sort by value, everything else by its text; nulls go first.
+ private sealed class ValueComparer : IComparer
+ {
+ public static readonly ValueComparer Instance = new();
+
+ public int Compare(object? x, object? y)
+ {
+ if (x is null || y is null) return x is null ? (y is null ? 0 : -1) : 1;
+ if (x is string || y is string || x.GetType() != y.GetType() || x is not IComparable comparable)
+ return string.Compare(Convert.ToString(x, CultureInfo.CurrentCulture), Convert.ToString(y, CultureInfo.CurrentCulture), StringComparison.CurrentCultureIgnoreCase);
+ return comparable.CompareTo(y);
+ }
+ }
+}
+
+// One column of a DataTable.
+public partial class DataTableColumn
+{
+ public string? Header { get; set; }
+
+ // The name of the item property this column shows.
+ public string? Property { get; set; }
+
+ // Reads the column's value from an item; replaces Property.
+ public Func? Value { get; set; }
+
+ // A .NET format string for the value, e.g. "C" or "MMM d, yyyy".
+ public string? Format { get; set; }
+
+ // A Grid column width: "*", "2*" or a fixed number. Auto is not supported.
+ public string Width { get; set; } = "*";
+
+ public bool IsSortable { get; set; } = true;
+
+ public bool IsBold { get; set; }
+
+ public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
+
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Property is looked up by name on the item's runtime type; set Value instead when trimming.")]
+ internal object? Read(object item)
+ {
+ if (Value != null) return Value(item);
+ return string.IsNullOrEmpty(Property) ? null : item.GetType().GetProperty(Property)?.GetValue(item);
+ }
+
+ internal string Text(object item)
+ {
+ var value = Read(item);
+ if (value is null) return string.Empty;
+ return !string.IsNullOrEmpty(Format) && value is IFormattable formattable
+ ? formattable.ToString(Format, CultureInfo.CurrentCulture)
+ : Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
+ }
+}
diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml
index 14da721..8a29d83 100644
--- a/examples/MAUI.Demo/MainPage.xaml
+++ b/examples/MAUI.Demo/MainPage.xaml
@@ -830,6 +830,17 @@
+
+
+
+
+
+
+
+
+
+
+ {
+ new DemoInvoice("INV001", "Shewart Shepherd", "Paid", 250.00m, new DateTime(2026, 9, 2)),
+ new DemoInvoice("INV002", "ShellUI", "Pending", 150.00m, new DateTime(2026, 9, 5)),
+ new DemoInvoice("INV003", "ShellIcons", "Unpaid", 350.00m, new DateTime(2026, 9, 9)),
+ new DemoInvoice("INV004", "ShellDocs", "Paid", 450.00m, new DateTime(2026, 9, 12)),
+ new DemoInvoice("INV005", "Shewart Shepherd", "Paid", 550.00m, new DateTime(2026, 9, 14)),
+ new DemoInvoice("INV006", "ShellUI Native", "Pending", 200.00m, new DateTime(2026, 9, 18)),
+ new DemoInvoice("INV007", "ShellUI", "Unpaid", 300.00m, new DateTime(2026, 9, 21)),
+ new DemoInvoice("INV008", "ShellDocs", "Paid", 75.50m, new DateTime(2026, 9, 24)),
+ new DemoInvoice("INV009", "ShellIcons", "Paid", 1200.00m, new DateTime(2026, 9, 27)),
+ new DemoInvoice("INV010", "ShellUI Native", "Pending", 89.99m, new DateTime(2026, 10, 1)),
+ new DemoInvoice("INV011", "Shewart Shepherd", "Unpaid", 640.00m, new DateTime(2026, 10, 4)),
+ new DemoInvoice("INV012", "ShellUI", "Paid", 310.25m, new DateTime(2026, 10, 8)),
+ };
CountrySelect.ItemsSource = new List { "United States", "Canada", "United Kingdom", "Germany", "Japan" };
TagsInput.Tags = new List { "maui", "design-system" };
FrameworkMultiSelect.ItemsSource = new List
@@ -133,6 +148,9 @@ private void OnMultiSelectChanged(object? sender, IReadOnlyList values)
private void OnCarouselChanged(object? sender, int position) => Report($"Carousel: slide {position + 1}");
private TableRow? _selectedRow;
+ private void OnDataTableSelection(object? sender, IReadOnlyList items) =>
+ Report($"Data table: {items.Count} selected ({string.Join(", ", items.Cast().Select(i => i.Number))})");
+ private void OnDataTableRow(object? sender, object item) => Report($"Data table row: {((DemoInvoice)item).Number}");
private void OnTableRowTapped(object? sender, EventArgs e)
{
if (sender is not TableRow row) return;
@@ -173,3 +191,5 @@ private void OnBreadcrumbClicked(object? sender, EventArgs e)
Report($"Breadcrumb: {item.Text}");
}
}
+
+public sealed record DemoInvoice(string Number, string Customer, string Status, decimal Amount, DateTime Date);
diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py
index 6e7714f..05bc2d3 100644
--- a/scripts/sync-templates.py
+++ b/scripts/sync-templates.py
@@ -100,6 +100,8 @@
["command", "palette", "search", "menu"]),
"NavigationMenu": ("navigation-menu", "Navigation Menu", "Row of top-level links that open panels of links", "Navigation",
["navigation", "menu", "header", "links"]),
+ "DataTable": ("data-table", "Data Table", "Table filled from a list, with filter, sorting, selection and pages", "DataDisplay",
+ ["data", "table", "grid", "sort", "filter", "pagination"]),
"DateRangePicker": ("date-range-picker", "Date Range Picker", "Picks a start and an end date from one calendar", "Form",
["date", "range", "picker", "calendar"]),
"FileUpload": ("file-upload", "File Upload", "Drop zone that opens the file picker and lists the picked files", "Form",
@@ -112,6 +114,7 @@
"DatePicker": ["calendar"],
"DateRangePicker": ["calendar"],
"ButtonGroup": ["button"],
+ "DataTable": ["table", "input", "checkbox"],
"TagInput": ["wrap-layout"],
"MultiSelect": ["wrap-layout"],
}
diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs
index 7d540c4..312a2c1 100644
--- a/src/ShellUI.Native.Templates/ComponentRegistry.cs
+++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs
@@ -131,7 +131,8 @@ public static class ComponentRegistry
{ "input-group", (InputGroupTemplate.Metadata, InputGroupTemplate.Contents) },
{ "menubar", (MenubarTemplate.Metadata, MenubarTemplate.Contents) },
{ "command", (CommandTemplate.Metadata, CommandTemplate.Contents) },
- { "navigation-menu", (NavigationMenuTemplate.Metadata, NavigationMenuTemplate.Contents) }
+ { "navigation-menu", (NavigationMenuTemplate.Metadata, NavigationMenuTemplate.Contents) },
+ { "data-table", (DataTableTemplate.Metadata, DataTableTemplate.Contents) }
};
// Metadata-only view for callers that don't need the per-platform payload.
diff --git a/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs b/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs
new file mode 100644
index 0000000..205b542
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs
@@ -0,0 +1,923 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class DataTableTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "data-table",
+ DisplayName = "Data Table",
+ Description = "Table filled from a list, with filter, sorting, selection and pages",
+ Category = ComponentCategory.DataDisplay,
+ FilePath = "DataTable.cs",
+ Dependencies = new List { "shell", "icon", "button", "table", "input", "checkbox" },
+ Tags = new List { "data", "table", "grid", "sort", "filter", "pagination" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Collections;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using YourProjectNamespace.Components.UI.Variants;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Data table — a Table filled from a list of objects, with a filter field, sortable column
+// headers, optional row selection and pages.
+//
+//
+//
+//
+//
+// A column reads its Property from each item by name. With trimming or NativeAOT, set the
+// column's Value in code instead: column.Value = item => ((Invoice)item).Number;
+[ContentProperty(nameof(Columns))]
+public partial class DataTable : ContentView
+{
+ public static readonly BindableProperty ItemsSourceProperty =
+ BindableProperty.Create(nameof(ItemsSource), typeof(IEnumerable), typeof(DataTable), null,
+ propertyChanged: (b, o, n) => ((DataTable)b).OnItemsSourceChanged(o, n));
+
+ public static readonly BindableProperty ShowFilterProperty =
+ BindableProperty.Create(nameof(ShowFilter), typeof(bool), typeof(DataTable), true,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ public static readonly BindableProperty FilterPlaceholderProperty =
+ BindableProperty.Create(nameof(FilterPlaceholder), typeof(string), typeof(DataTable), ""Filter..."",
+ propertyChanged: (b, o, n) => ((DataTable)b)._filter.Placeholder = n as string ?? string.Empty);
+
+ public static readonly BindableProperty ShowPaginationProperty =
+ BindableProperty.Create(nameof(ShowPagination), typeof(bool), typeof(DataTable), true,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ // Rows per page while ShowPagination is on.
+ public static readonly BindableProperty PageSizeProperty =
+ BindableProperty.Create(nameof(PageSize), typeof(int), typeof(DataTable), 10,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ // Adds a checkbox column; the header checkbox selects every row that passes the filter.
+ public static readonly BindableProperty ShowSelectionProperty =
+ BindableProperty.Create(nameof(ShowSelection), typeof(bool), typeof(DataTable), false,
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(DataTable), ""No results."",
+ propertyChanged: (b, o, n) => ((DataTable)b).Refresh());
+
+ // Below this width the table scrolls sideways instead of squeezing its columns. 0 never scrolls.
+ public static readonly BindableProperty MinimumContentWidthProperty =
+ BindableProperty.Create(nameof(MinimumContentWidth), typeof(double), typeof(DataTable), 0d,
+ propertyChanged: (b, o, n) => ((DataTable)b)._table.MinimumContentWidth = (double)n);
+
+ public IEnumerable? ItemsSource
+ {
+ get => (IEnumerable?)GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public bool ShowFilter
+ {
+ get => (bool)GetValue(ShowFilterProperty);
+ set => SetValue(ShowFilterProperty, value);
+ }
+
+ public string FilterPlaceholder
+ {
+ get => (string)GetValue(FilterPlaceholderProperty);
+ set => SetValue(FilterPlaceholderProperty, value);
+ }
+
+ public bool ShowPagination
+ {
+ get => (bool)GetValue(ShowPaginationProperty);
+ set => SetValue(ShowPaginationProperty, value);
+ }
+
+ public int PageSize
+ {
+ get => (int)GetValue(PageSizeProperty);
+ set => SetValue(PageSizeProperty, value);
+ }
+
+ public bool ShowSelection
+ {
+ get => (bool)GetValue(ShowSelectionProperty);
+ set => SetValue(ShowSelectionProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => (double)GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ public IList Columns => _columns;
+
+ // The selected items, in the order of ItemsSource.
+ public IReadOnlyList SelectedItems => Source().Where(_selected.Contains).ToList();
+
+ // The page shown, from 1.
+ public int Page { get; private set; } = 1;
+
+ public int PageCount { get; private set; } = 1;
+
+ // The text in the filter field.
+ public string Filter
+ {
+ get => _filter.Text ?? string.Empty;
+ set => _filter.Text = value;
+ }
+
+ public event EventHandler>? SelectionChanged;
+
+ // Raised with the row's item.
+ public event EventHandler? RowTapped;
+
+ private readonly ObservableCollection _columns = new();
+ private readonly HashSet _selected = new();
+ private readonly Input _filter;
+ private readonly Table _table;
+ private readonly Grid _footer;
+ private readonly Label _summary;
+ private readonly Label _pageLabel;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private readonly List<(object Item, TableRow Row, Checkbox? Check)> _rowViews = new();
+ private Checkbox? _allCheck;
+ private List _view = new();
+ private DataTableColumn? _sortColumn;
+ private bool _descending;
+ private bool _rendering;
+
+ public DataTable()
+ {
+ _filter = new Input { Placeholder = FilterPlaceholder, MaximumWidthRequest = 384, HorizontalOptions = LayoutOptions.Start, MinimumWidthRequest = 240 };
+ _filter.TextChanged += (_, _) => { Page = 1; Refresh(); };
+
+ _table = new Table();
+
+ _summary = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _summary.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _pageLabel = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(0, 0, 4, 0) };
+ _pageLabel.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _previous = new Button { Text = ""Previous"", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _previous.Clicked += (_, _) => GoToPage(Page - 1);
+ _next = new Button { Text = ""Next"", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _next.Clicked += (_, _) => GoToPage(Page + 1);
+
+ var pager = new HorizontalStackLayout { Spacing = 8, VerticalOptions = LayoutOptions.Center, Children = { _pageLabel, _previous, _next } };
+ _footer = new Grid
+ {
+ ColumnSpacing = 12,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) }
+ };
+ _footer.Add(_summary, 0, 0);
+ _footer.Add(pager, 1, 0);
+
+ Content = new VerticalStackLayout { Spacing = 12, Children = { _filter, _table, _footer } };
+ _columns.CollectionChanged += (_, _) => Refresh();
+ Refresh();
+ }
+
+ // Re-reads the items and columns. Call it after changing an item's values or a column.
+ public void Refresh()
+ {
+ // The table and its parts don't exist yet while the constructor sets property defaults.
+ if (_footer is null) return;
+ var items = Source();
+ _selected.IntersectWith(items);
+
+ var words = Filter.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ IEnumerable view = ShowFilter && words.Length > 0
+ ? items.Where(item => words.All(word => _columns.Any(c => c.Text(item).Contains(word, StringComparison.CurrentCultureIgnoreCase))))
+ : items;
+ if (_sortColumn != null && _columns.Contains(_sortColumn))
+ {
+ var column = _sortColumn;
+ view = _descending
+ ? view.OrderByDescending(item => column.Read(item), ValueComparer.Instance)
+ : view.OrderBy(item => column.Read(item), ValueComparer.Instance);
+ }
+ _view = view.ToList();
+
+ var size = Math.Max(1, PageSize);
+ PageCount = ShowPagination ? Math.Max(1, (int)Math.Ceiling(_view.Count / (double)size)) : 1;
+ Page = Math.Clamp(Page, 1, PageCount);
+ Render(ShowPagination ? _view.Skip((Page - 1) * size).Take(size).ToList() : _view);
+ }
+
+ public void GoToPage(int page)
+ {
+ Page = page;
+ Refresh();
+ }
+
+ // Sorts by a column; call it again to reverse, and with null to restore the source order.
+ public void SortBy(DataTableColumn? column, bool descending = false)
+ {
+ _sortColumn = column;
+ _descending = descending;
+ Refresh();
+ }
+
+ public void ClearSelection()
+ {
+ if (_selected.Count == 0) return;
+ _selected.Clear();
+ OnSelectionChanged();
+ }
+
+ private List Source() => ItemsSource?.Cast().Where(item => item != null).ToList() ?? new List();
+
+ private void OnItemsSourceChanged(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnItemsChanged;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnItemsChanged;
+ Page = 1;
+ Refresh();
+ }
+
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Refresh();
+
+ private void Render(IReadOnlyList rows)
+ {
+ _rendering = true;
+ _filter.IsVisible = ShowFilter;
+ var widths = _columns.Select(c => string.IsNullOrWhiteSpace(c.Width) ? ""*"" : c.Width);
+ _table.Columns = string.Join("","", ShowSelection ? widths.Prepend(""48"") : widths);
+ _table.Rows.Clear();
+ _rowViews.Clear();
+ _allCheck = null;
+
+ var header = new TableHeader();
+ if (ShowSelection)
+ {
+ var all = new Checkbox { HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ SemanticProperties.SetDescription(all, ""Select all rows"");
+ all.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.UnionWith(_view);
+ else _selected.ExceptWith(_view);
+ OnSelectionChanged();
+ };
+ header.Children.Add(all);
+ _allCheck = all;
+ }
+ foreach (var column in _columns) header.Children.Add(CreateHead(column));
+ _table.Rows.Add(header);
+
+ foreach (var item in rows)
+ {
+ var row = new TableRow();
+ row.Tapped += (_, _) => RowTapped?.Invoke(this, item);
+ Checkbox? check = null;
+ if (ShowSelection)
+ {
+ check = new Checkbox { HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ SemanticProperties.SetDescription(check, ""Select row"");
+ check.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.Add(item);
+ else _selected.Remove(item);
+ OnSelectionChanged();
+ };
+ row.Children.Add(check);
+ }
+ _rowViews.Add((item, row, check));
+ foreach (var column in _columns)
+ row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment });
+ _table.Rows.Add(row);
+ }
+
+ if (rows.Count == 0)
+ {
+ // h-24 text-center, one cell across the table.
+ var empty = new TableHeader { Columns = ""*"" };
+ empty.Children.Add(new TableCell { Text = EmptyText ?? string.Empty, HorizontalTextAlignment = TextAlignment.Center, HeightRequest = 96 });
+ _table.Rows.Add(empty);
+ }
+
+ _footer.IsVisible = ShowPagination || ShowSelection;
+ ShowSelectionState();
+ _pageLabel.Text = $""Page {Page} of {PageCount}"";
+ _pageLabel.IsVisible = _previous.IsVisible = _next.IsVisible = ShowPagination;
+ _previous.IsEnabled = Page > 1;
+ _next.IsEnabled = Page < PageCount;
+ _rendering = false;
+ }
+
+ // Ticks, row tints and the count follow the selection without rebuilding the rows.
+ private void ShowSelectionState()
+ {
+ var rendering = _rendering;
+ _rendering = true;
+ foreach (var (item, row, check) in _rowViews)
+ {
+ var selected = _selected.Contains(item);
+ row.IsSelected = selected;
+ if (check != null) check.IsChecked = selected;
+ }
+ if (_allCheck != null) _allCheck.IsChecked = _view.Count > 0 && _view.All(_selected.Contains);
+ _summary.Text = ShowSelection
+ ? $""{_view.Count(_selected.Contains)} of {_view.Count} row(s) selected.""
+ : $""{_view.Count} row(s)."";
+ _rendering = rendering;
+ }
+
+ private void OnSelectionChanged()
+ {
+ ShowSelectionState();
+ SelectionChanged?.Invoke(this, SelectedItems);
+ }
+
+ // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction
+ // and cycles ascending, descending, unsorted on tap.
+ private View CreateHead(DataTableColumn column)
+ {
+ var label = new Label
+ {
+ Text = column.Header ?? string.Empty,
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ label.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ var row = new HorizontalStackLayout
+ {
+ Spacing = 6,
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = column.HorizontalTextAlignment switch
+ {
+ TextAlignment.End => LayoutOptions.End,
+ TextAlignment.Center => LayoutOptions.Center,
+ _ => LayoutOptions.Start
+ },
+ Children = { label }
+ };
+ var head = new ContentView { Content = row, HeightRequest = 44, Padding = new Thickness(16, 0) };
+ if (!column.IsSortable) return head;
+
+ var sorted = ReferenceEquals(_sortColumn, column);
+ row.Children.Add(new Icon
+ {
+ Name = !sorted ? IconName.ChevronsUpDown : _descending ? IconName.ArrowDown : IconName.ArrowUp,
+ Size = 14,
+ Token = sorted ? ShellToken.Foreground : ShellToken.MutedForeground,
+ VerticalOptions = LayoutOptions.Center
+ });
+ if (sorted) label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ SemanticProperties.SetDescription(head, $""Sort by {column.Header}"");
+ // A layout with no background only takes pointer input over its children.
+ head.BackgroundColor = Colors.Transparent;
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Dispatcher.Dispatch(() => ToggleSort(column));
+ head.GestureRecognizers.Add(tap);
+ return head;
+ }
+
+ private void ToggleSort(DataTableColumn column)
+ {
+ if (!ReferenceEquals(_sortColumn, column)) SortBy(column);
+ else if (!_descending) SortBy(column, descending: true);
+ else SortBy(null);
+ }
+
+ // Numbers and dates sort by value, everything else by its text; nulls go first.
+ private sealed class ValueComparer : IComparer
+ {
+ public static readonly ValueComparer Instance = new();
+
+ public int Compare(object? x, object? y)
+ {
+ if (x is null || y is null) return x is null ? (y is null ? 0 : -1) : 1;
+ if (x is string || y is string || x.GetType() != y.GetType() || x is not IComparable comparable)
+ return string.Compare(Convert.ToString(x, CultureInfo.CurrentCulture), Convert.ToString(y, CultureInfo.CurrentCulture), StringComparison.CurrentCultureIgnoreCase);
+ return comparable.CompareTo(y);
+ }
+ }
+}
+
+// One column of a DataTable.
+public partial class DataTableColumn
+{
+ public string? Header { get; set; }
+
+ // The name of the item property this column shows.
+ public string? Property { get; set; }
+
+ // Reads the column's value from an item; replaces Property.
+ public Func? Value { get; set; }
+
+ // A .NET format string for the value, e.g. ""C"" or ""MMM d, yyyy"".
+ public string? Format { get; set; }
+
+ // A Grid column width: ""*"", ""2*"" or a fixed number. Auto is not supported.
+ public string Width { get; set; } = ""*"";
+
+ public bool IsSortable { get; set; } = true;
+
+ public bool IsBold { get; set; }
+
+ public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
+
+ [UnconditionalSuppressMessage(""Trimming"", ""IL2075"", Justification = ""Property is looked up by name on the item's runtime type; set Value instead when trimming."")]
+ internal object? Read(object item)
+ {
+ if (Value != null) return Value(item);
+ return string.IsNullOrEmpty(Property) ? null : item.GetType().GetProperty(Property)?.GetValue(item);
+ }
+
+ internal string Text(object item)
+ {
+ var value = Read(item);
+ if (value is null) return string.Empty;
+ return !string.IsNullOrEmpty(Format) && value is IFormattable formattable
+ ? formattable.ToString(Format, CultureInfo.CurrentCulture)
+ : Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using YourProjectNamespace.Components.UI.Variants;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Data table — a Table filled from a list of objects, with a filter field, sortable column
+// headers, optional row selection and pages. Sortable headers are tab stops (Enter / Space).
+//
+//
+//
+//
+//
+// A column reads its Property from each item by name. With trimming or NativeAOT, set the
+// column's Value in code instead: column.Value = item => ((Invoice)item).Number;
+public class DataTable : Border
+{
+ public static readonly StyledProperty ItemsSourceProperty =
+ AvaloniaProperty.Register(nameof(ItemsSource));
+
+ public static readonly StyledProperty ShowFilterProperty =
+ AvaloniaProperty.Register(nameof(ShowFilter), true);
+
+ public static readonly StyledProperty FilterPlaceholderProperty =
+ AvaloniaProperty.Register(nameof(FilterPlaceholder), ""Filter..."");
+
+ public static readonly StyledProperty ShowPaginationProperty =
+ AvaloniaProperty.Register(nameof(ShowPagination), true);
+
+ // Rows per page while ShowPagination is on.
+ public static readonly StyledProperty PageSizeProperty =
+ AvaloniaProperty.Register(nameof(PageSize), 10);
+
+ // Adds a checkbox column; the header checkbox selects every row that passes the filter.
+ public static readonly StyledProperty ShowSelectionProperty =
+ AvaloniaProperty.Register(nameof(ShowSelection));
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), ""No results."");
+
+ // Below this width the table scrolls sideways instead of squeezing its columns. 0 never scrolls.
+ public static readonly StyledProperty MinimumContentWidthProperty =
+ AvaloniaProperty.Register(nameof(MinimumContentWidth));
+
+ private readonly HashSet _selected = new();
+ private readonly List<(object Item, TableRow Row, Checkbox? Check)> _rowViews = new();
+ private readonly Input _filter;
+ private readonly Table _table = new();
+ private readonly Grid _footer;
+ private readonly TextBlock _summary;
+ private readonly TextBlock _pageLabel;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private Checkbox? _allCheck;
+ private List _view = new();
+ private DataTableColumn? _sortColumn;
+ private DataTableColumn? _refocus;
+ private bool _descending;
+ private bool _rendering;
+
+ static DataTable()
+ {
+ ItemsSourceProperty.Changed.AddClassHandler((t, e) => t.OnItemsSourceChanged(e.OldValue, e.NewValue));
+ ShowFilterProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ FilterPlaceholderProperty.Changed.AddClassHandler((t, e) => t._filter.Placeholder = (string?)e.NewValue);
+ ShowPaginationProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ PageSizeProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ ShowSelectionProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ EmptyTextProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ MinimumContentWidthProperty.Changed.AddClassHandler((t, e) => t._table.MinimumContentWidth = (double)e.NewValue!);
+ }
+
+ public IEnumerable? ItemsSource
+ {
+ get => GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public bool ShowFilter
+ {
+ get => GetValue(ShowFilterProperty);
+ set => SetValue(ShowFilterProperty, value);
+ }
+
+ public string? FilterPlaceholder
+ {
+ get => GetValue(FilterPlaceholderProperty);
+ set => SetValue(FilterPlaceholderProperty, value);
+ }
+
+ public bool ShowPagination
+ {
+ get => GetValue(ShowPaginationProperty);
+ set => SetValue(ShowPaginationProperty, value);
+ }
+
+ public int PageSize
+ {
+ get => GetValue(PageSizeProperty);
+ set => SetValue(PageSizeProperty, value);
+ }
+
+ public bool ShowSelection
+ {
+ get => GetValue(ShowSelectionProperty);
+ set => SetValue(ShowSelectionProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ [Content]
+ public AvaloniaList Columns { get; } = new();
+
+ // The selected items, in the order of ItemsSource.
+ public IReadOnlyList SelectedItems => Source().Where(_selected.Contains).ToList();
+
+ // The page shown, from 1.
+ public int Page { get; private set; } = 1;
+
+ public int PageCount { get; private set; } = 1;
+
+ // The text in the filter field.
+ public string Filter
+ {
+ get => _filter.Text ?? string.Empty;
+ set => _filter.Text = value;
+ }
+
+ public event EventHandler>? SelectionChanged;
+
+ // Raised with the row's item.
+ public event EventHandler? RowTapped;
+
+ public DataTable()
+ {
+ _filter = new Input { Placeholder = FilterPlaceholder, MaxWidth = 384, MinWidth = 240, HorizontalAlignment = HorizontalAlignment.Left };
+ _filter.TextChanged += (_, _) => { Page = 1; Refresh(); };
+
+ _summary = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _summary.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _pageLabel = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) };
+ _pageLabel.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _previous = new Button { Text = ""Previous"", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _previous.Clicked += (_, _) => GoToPage(Page - 1);
+ _next = new Button { Text = ""Next"", Variant = ButtonVariant.Outline, Size = ButtonSize.Sm };
+ _next.Clicked += (_, _) => GoToPage(Page + 1);
+
+ var pager = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center, Children = { _pageLabel, _previous, _next } };
+ Grid.SetColumn(pager, 1);
+ _footer = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 12, Children = { _summary, pager } };
+
+ Child = new StackPanel { Spacing = 12, Children = { _filter, _table, _footer } };
+ Columns.CollectionChanged += (_, _) => Refresh();
+ Refresh();
+ }
+
+ // Re-reads the items and columns. Call it after changing an item's values or a column.
+ public void Refresh()
+ {
+ // The table and its parts don't exist yet while property defaults are applied.
+ if (_footer is null) return;
+ var items = Source();
+ _selected.IntersectWith(items);
+
+ var words = Filter.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ IEnumerable view = ShowFilter && words.Length > 0
+ ? items.Where(item => words.All(word => Columns.Any(c => c.Text(item).Contains(word, StringComparison.CurrentCultureIgnoreCase))))
+ : items;
+ if (_sortColumn != null && Columns.Contains(_sortColumn))
+ {
+ var column = _sortColumn;
+ view = _descending
+ ? view.OrderByDescending(item => column.Read(item), ValueComparer.Instance)
+ : view.OrderBy(item => column.Read(item), ValueComparer.Instance);
+ }
+ _view = view.ToList();
+
+ var size = Math.Max(1, PageSize);
+ PageCount = ShowPagination ? Math.Max(1, (int)Math.Ceiling(_view.Count / (double)size)) : 1;
+ Page = Math.Clamp(Page, 1, PageCount);
+ Render(ShowPagination ? _view.Skip((Page - 1) * size).Take(size).ToList() : _view);
+ }
+
+ public void GoToPage(int page)
+ {
+ Page = page;
+ Refresh();
+ }
+
+ // Sorts by a column; call it again to reverse, and with null to restore the source order.
+ public void SortBy(DataTableColumn? column, bool descending = false)
+ {
+ _sortColumn = column;
+ _descending = descending;
+ Refresh();
+ }
+
+ public void ClearSelection()
+ {
+ if (_selected.Count == 0) return;
+ _selected.Clear();
+ OnSelectionChanged();
+ }
+
+ private List Source() => ItemsSource?.Cast().Where(item => item != null).ToList() ?? new List();
+
+ private void OnItemsSourceChanged(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnItemsChanged;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnItemsChanged;
+ Page = 1;
+ Refresh();
+ }
+
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Refresh();
+
+ private void Render(IReadOnlyList rows)
+ {
+ _rendering = true;
+ _filter.IsVisible = ShowFilter;
+ var widths = Columns.Select(c => string.IsNullOrWhiteSpace(c.Width) ? ""*"" : c.Width);
+ _table.Columns = string.Join("","", ShowSelection ? widths.Prepend(""48"") : widths);
+ _table.Rows.Clear();
+ _rowViews.Clear();
+ _allCheck = null;
+
+ var header = new TableHeader();
+ if (ShowSelection)
+ {
+ var all = new Checkbox { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ AutomationProperties.SetName(all, ""Select all rows"");
+ all.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.UnionWith(_view);
+ else _selected.ExceptWith(_view);
+ OnSelectionChanged();
+ };
+ header.Children.Add(all);
+ _allCheck = all;
+ }
+ foreach (var column in Columns) header.Children.Add(CreateHead(column));
+ _table.Rows.Add(header);
+
+ foreach (var item in rows)
+ {
+ var row = new TableRow();
+ // A click on the row's checkbox selects; it is not a click on the row.
+ row.Tapped += (_, e) =>
+ {
+ if (e.Source is Visual source && source.FindAncestorOfType(includeSelf: true) != null) return;
+ RowTapped?.Invoke(this, item);
+ };
+ Checkbox? check = null;
+ if (ShowSelection)
+ {
+ check = new Checkbox { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ AutomationProperties.SetName(check, ""Select row"");
+ check.CheckedChanged += (_, isChecked) =>
+ {
+ if (_rendering) return;
+ if (isChecked) _selected.Add(item);
+ else _selected.Remove(item);
+ OnSelectionChanged();
+ };
+ row.Children.Add(check);
+ }
+ _rowViews.Add((item, row, check));
+ foreach (var column in Columns)
+ row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment });
+ _table.Rows.Add(row);
+ }
+
+ if (rows.Count == 0)
+ {
+ // h-24 text-center, one cell across the table.
+ var empty = new TableHeader { Columns = ""*"" };
+ empty.Children.Add(new TableCell { Text = EmptyText ?? string.Empty, HorizontalTextAlignment = TextAlignment.Center, Height = 96 });
+ _table.Rows.Add(empty);
+ }
+
+ _footer.IsVisible = ShowPagination || ShowSelection;
+ ShowSelectionState();
+ _pageLabel.Text = $""Page {Page} of {PageCount}"";
+ _pageLabel.IsVisible = _previous.IsVisible = _next.IsVisible = ShowPagination;
+ _previous.IsEnabled = Page > 1;
+ _next.IsEnabled = Page < PageCount;
+ _rendering = false;
+ }
+
+ // Ticks, row tints and the count follow the selection without rebuilding the rows.
+ private void ShowSelectionState()
+ {
+ var rendering = _rendering;
+ _rendering = true;
+ foreach (var (item, row, check) in _rowViews)
+ {
+ var selected = _selected.Contains(item);
+ row.IsSelected = selected;
+ if (check != null) check.IsChecked = selected;
+ }
+ if (_allCheck != null) _allCheck.IsChecked = _view.Count > 0 && _view.All(_selected.Contains);
+ _summary.Text = ShowSelection
+ ? $""{_view.Count(_selected.Contains)} of {_view.Count} row(s) selected.""
+ : $""{_view.Count} row(s)."";
+ _rendering = rendering;
+ }
+
+ private void OnSelectionChanged()
+ {
+ ShowSelectionState();
+ SelectionChanged?.Invoke(this, SelectedItems);
+ }
+
+ // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction
+ // and cycles ascending, descending, unsorted on click.
+ private Control CreateHead(DataTableColumn column)
+ {
+ var label = new TextBlock
+ {
+ Text = column.Header ?? string.Empty,
+ FontSize = 14,
+ FontWeight = FontWeight.Medium,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ var row = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 6,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = column.HorizontalTextAlignment switch
+ {
+ TextAlignment.End or TextAlignment.Right => HorizontalAlignment.Right,
+ TextAlignment.Center => HorizontalAlignment.Center,
+ _ => HorizontalAlignment.Left
+ },
+ Children = { label }
+ };
+ var head = new Border { Child = row, Height = 44, Padding = new Thickness(16, 0) };
+ if (!column.IsSortable) return head;
+
+ var sorted = ReferenceEquals(_sortColumn, column);
+ row.Children.Add(new Icon
+ {
+ Kind = !sorted ? IconName.ChevronsUpDown : _descending ? IconName.ArrowDown : IconName.ArrowUp,
+ Size = 14,
+ Token = sorted ? ShellToken.Foreground : ShellToken.MutedForeground,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ if (sorted) label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ AutomationProperties.SetName(head, $""Sort by {column.Header}"");
+ // Transparent, not null, so the whole header cell takes the pointer.
+ head.Background = Brushes.Transparent;
+ head.Cursor = new Cursor(StandardCursorType.Hand);
+ head.CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ ShellFocus.Ring(head, head);
+ // Sorting rebuilds the header row, so the rebuild waits until this event is done.
+ head.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Dispatcher.UIThread.Post(() => ToggleSort(column));
+ e.Handled = true;
+ };
+ head.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ // Keep keyboard focus on this column's new header cell.
+ _refocus = column;
+ Dispatcher.UIThread.Post(() => ToggleSort(column));
+ e.Handled = true;
+ };
+ if (ReferenceEquals(_refocus, column))
+ {
+ _refocus = null;
+ Dispatcher.UIThread.Post(() => head.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
+ }
+ return head;
+ }
+
+ private void ToggleSort(DataTableColumn column)
+ {
+ if (!ReferenceEquals(_sortColumn, column)) SortBy(column);
+ else if (!_descending) SortBy(column, descending: true);
+ else SortBy(null);
+ }
+
+ // Numbers and dates sort by value, everything else by its text; nulls go first.
+ private sealed class ValueComparer : IComparer
+ {
+ public static readonly ValueComparer Instance = new();
+
+ public int Compare(object? x, object? y)
+ {
+ if (x is null || y is null) return x is null ? (y is null ? 0 : -1) : 1;
+ if (x is string || y is string || x.GetType() != y.GetType() || x is not IComparable comparable)
+ return string.Compare(Convert.ToString(x, CultureInfo.CurrentCulture), Convert.ToString(y, CultureInfo.CurrentCulture), StringComparison.CurrentCultureIgnoreCase);
+ return comparable.CompareTo(y);
+ }
+ }
+}
+
+// One column of a DataTable.
+public class DataTableColumn
+{
+ public string? Header { get; set; }
+
+ // The name of the item property this column shows.
+ public string? Property { get; set; }
+
+ // Reads the column's value from an item; replaces Property.
+ public Func? Value { get; set; }
+
+ // A .NET format string for the value, e.g. ""C"" or ""MMM d, yyyy"".
+ public string? Format { get; set; }
+
+ // A Grid column width: ""*"", ""2*"" or a fixed number. Auto is not supported.
+ public string Width { get; set; } = ""*"";
+
+ public bool IsSortable { get; set; } = true;
+
+ public bool IsBold { get; set; }
+
+ public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
+
+ [UnconditionalSuppressMessage(""Trimming"", ""IL2075"", Justification = ""Property is looked up by name on the item's runtime type; set Value instead when trimming."")]
+ internal object? Read(object item)
+ {
+ if (Value != null) return Value(item);
+ return string.IsNullOrEmpty(Property) ? null : item.GetType().GetProperty(Property)?.GetValue(item);
+ }
+
+ internal string Text(object item)
+ {
+ var value = Read(item);
+ if (value is null) return string.Empty;
+ return !string.IsNullOrEmpty(Format) && value is IFormattable formattable
+ ? formattable.ToString(Format, CultureInfo.CurrentCulture)
+ : Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
+ }
+}
+"
+ };
+}
diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
index 8e0736c..a949617 100644
--- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs
+++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
@@ -124,6 +124,7 @@ public static IEnumerable AvaloniaComponents()
[InlineData("menubar")]
[InlineData("command")]
[InlineData("navigation-menu")]
+ [InlineData("data-table")]
public void Ported_components_have_Avalonia_content(string name)
{
Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia)));