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 @@ + +