TableView: reorganize and order IDL files (#12382)

Reorganizes the IDL files added for the TableView effort so they match
the layout and ordering of established controls (ItemsView, ScrollView,
SelectorBar, InfoBar, TeachingTip, Repeater,
`controls/idl/Microsoft.UI.Xaml.Controls.idl`). It also moves the IDL
comment notes into the TableView dev spec and rewrites that spec to be
accurate and concise. No API is added, removed or renamed.

### Files
- `controls/dev/TableView/TableView.idl`
- `controls/dev/TableView/TableViewSource.idl`
- `controls/dev/dll-tabular/Microsoft.UI.Xaml.Controls.Tabular.idl`
- `controls/dev/ResizeGripper/ResizeGripper.idl`
- `controls/dev/ResizeGripper/ResizeGripperAutomationPeer.idl`
- `controls/dev/TabularShaping/SortDirection.idl`
- `docs/design-notes/TabularControls/TableView-dev-spec.md`

### IDL changes
- **Type order:** enums, interfaces, column classes, event args (in the
same order as the events), helper classes, the control, then automation
peers (in the same order as the helpers).
- **Member order:** constructor, properties, methods, events, then
statics. Dependency-property statics follow the order of their
properties.
- **Formatting:** matches the reference IDLs.
- **Comments:** documentation and rationale comments moved into the dev
spec. A coverage check confirms all 111 comment blocks are captured.
Only one-line "do not ..." guard comments stay in the IDLs
(SortDirection namespace, Tabular IDL ordering and namespace,
ResizeGripper private namespace, frozen overload metadata on
`TableViewSource`), and each points to the dev spec.

### Dev spec changes
- **Size and structure:** rewritten to be accurate, concise and
consistent with the other dev specs in the repo (TitleBar, SelectorBar).
It went from about 14,200 to about 4,900 words and follows the repo
template order: Description, Design considerations, Architectural
overview, Performance, Debugging, Implementation details, Appendix.
- **Content:** IDL notes are folded into the feature sections.
Duplicated and stale text is removed.
- **Accuracy:** every claim was cross-checked against the code, and
about 30 incorrect or outdated statements were fixed. Examples:
- the "Key public components" list now matches the IDLs (removed
`TableViewEditingUnit`, `TableViewEditAction.Discard` and the public
editing hooks)
  - no edit deferral in this release
  - row template parts and states
  - the body repeater's `ItemTemplate` is `TableViewRowTemplateSelector`
  - header scroller mode and row cache length
  - the cell wrapper is a `Grid`
- cell UIA control type, and `ITransformProvider` on the column-header
peer
  - keyboard map and density resource keys
  - why `TableView` is unsealed
- **Cited wording kept:** headings and phrases cited by code comments
("Keyboard", "Re-shape while focused", "Sticky headers", etc.) are
unchanged.

### ABI note
Member reordering changes the vtable order and IIDs of `ITableView`,
`ITableViewColumn`, `ITableViewColumnStatics`, `ITableViewSource` and
the private `IResizeGripper`.
- All TableView types are `[MUX_PREVIEW]` (Feature_Experimental), and
ResizeGripper is `[MUX_INTERNAL]` in the private namespace.
- Preview consumers, including C# `TableViewColumn` subclasses, need to
rebuild.
- `ITableViewColumnOverrides`, `ITableViewStatics` and
`ITableViewSourceStatics` are unchanged.

### Follow-ups (not in this PR)
- The API spec (`docs/api-specs/TableView/TableView-spec.md`) still
calls column resize "planned" and says duplicate row identity always
fails fast. Update it, and move the dev spec's "API notes not yet in the
API spec" appendix into it.
- About 100 code and test comments cite `TableView.idl:<line>` or
`dev-spec:NNN` line numbers. Rewrite them to section names.
- A few code comments in `TableView.h` and `TableView_Editing.cpp` still
mention a "deferral" that doesn't exist.

### Validation
- **IDL equivalence:** a script that strips comments and whitespace and
compares types, attributes and members confirms the six IDLs differ only
in order and comments.
- **Build:** the Tabular idl and dll projects build with 0 warnings and
0 errors, and checked-in generated files are unchanged.
- **WinMD comparison** (Tabular, Private.Controls, XamlTypeInfo) against
main: identical types, members, signatures and attributes. The only
differences are the 5 IIDs above.
- **Tests:** the TableView suite (882 tests) ran on a test VM. 876
passed and 3 are `[Ignore]`. The remaining 3 failed or hung in the full
run but passed when rerun alone on this branch and on the baseline. The
grouped automation-peer chunk hangs intermittently on both this branch
and baseline. Later commits change only type order, comments and docs.
- **Dev spec:** independent fact-check and readability reviews were run,
and all findings are addressed. A final review of the last fixes found
two issues, both corrected.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7e074e0a-bef2-4f87-ae83-4ebdf5da11b2
This commit is contained in:
Hitesh KumarandCopilot authored and GitHub committed 2026-10-27 01:55:58 +05:30
1 parent e43de2ad2c
commit b22743a863
7 files changed
+464 -1123

No files matched your search

+4 -41
View File
@@ -1,9 +1,7 @@
// Framework-internal: the private namespace (not the MUX_INTERNAL attribute) is what keeps this
// out of every shipped WinMD. Move to MU_XCP_NAMESPACE + MUX_PREVIEW only if it is ever exposed.
// Do not move out of the private namespace: it, not [MUX_INTERNAL], keeps these types out of shipped winmds.
namespace MU_PRIVATE_CONTROLS_NAMESPACE
{
// TotalDelta spans the whole gesture from DragStarted; Delta is the change since the last raise.
[MUX_INTERNAL]
[default_interface]
[webhosthidden]
@@ -13,8 +11,6 @@ runtimeclass ResizeGripperDragDeltaEventArgs
Double TotalDelta{ get; };
}
// Canceled means the gesture was torn down (pointer canceled, capture lost, unloaded) rather than
// released: the host should revert to the width it captured at DragStarted, not keep the last one.
[MUX_INTERNAL]
[default_interface]
[webhosthidden]
@@ -24,17 +20,6 @@ runtimeclass ResizeGripperDragCompletedEventArgs
Boolean Canceled{ get; };
}
// Drag handle. Owns the gesture, its own presentation and the keyboard step; owns no value and no
// bounds - it reports how far the user has dragged, and the host clamps that and applies it.
//
// Template contract (MIDL3 does not surface [TemplatePart], so this block is it):
// Required parts: none. No GetTemplateChild call; a template may omit everything.
// State targets: "Separator" - the visual-state setters target it by name.
// Visual state groups: CommonStates - Normal, PointerOver, Pressed, Disabled.
// OrientationStates - Horizontal, Vertical; follows DragOrientation.
// Styling keys: ResizeGripperSeparatorBrush, ResizeGripperSeparatorThickness,
// ResizeGripperSeparatorThicknessVertical (ResizeGripper_themeresources.xaml).
// RTL: no mirrored state; only the drag delta is mirrored in code.
[MUX_INTERNAL]
[webhosthidden]
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
@@ -46,44 +31,22 @@ unsealed runtimeclass ResizeGripper : Microsoft.UI.Xaml.Controls.Control
[MUX_DEFAULT_VALUE("false")]
Boolean IsDragging{ get; };
// The frame drags are measured in. A host sets this to an ancestor that does NOT move while the
// drag resizes something: the gripper travels with the edge it drags, so the default frame (the
// gripper itself) feeds the resize back into the gesture and stalls it, while the XamlRoot
// content picks up any scale or zoom applied between it and the gripper. Null falls back to the
// XamlRoot content, which is correct whenever nothing between it and the gripper is scaled.
Microsoft.UI.Xaml.UIElement ManipulationContainer{ get; set; };
// Qualifier the automation peer prepends to its localized name, so N grippers in one band are
// distinguishable ("Price, Resize gripper"). Declared rather than read off Tag: Tag is a
// general-purpose slot a host may already be using, and a silent wrong name is an a11y bug.
String OwnerName{ get; set; };
// The axis the drag is measured along, which for a divider is perpendicular to how it looks:
// Horizontal drags left/right. Named DragOrientation rather than Orientation because the
// primitive owns no shape - the host sizes it - so the control's own layout axis is not
// something it can describe. Only horizontal mirrors under RTL.
[MUX_DEFAULT_VALUE("winrt::Orientation::Horizontal")]
Microsoft.UI.Xaml.Controls.Orientation DragOrientation{ get; set; };
// How far one arrow key travels; Shift multiplies it. Step size is host policy, so it is
// settable rather than baked in.
[MUX_DEFAULT_VALUE("c_defaultKeyboardIncrement")]
Double KeyboardIncrement{ get; set; };
// Raise the same events a pointer drag would, so a keyboard step is indistinguishable from the
// gesture. TryDrag takes the offset from where the drag began and returns whether it moved far
// enough to raise DragDelta. Pass canceled to EndDrag when the gesture was torn down.
void BeginDrag();
Boolean TryDrag(Double totalDelta);
void EndDrag(Boolean canceled);
// One arrow key as a complete one-step drag. The gripper acts on its own keys when a host makes
// it focusable; a host that keeps focus on its own element calls this instead, so neither path
// re-derives the direction, the RTL mirror or the step. False when the key is not on this axis.
Boolean TryKeyboardStep(Windows.System.VirtualKey key);
event Windows.Foundation.TypedEventHandler<ResizeGripper, ResizeGripperDragDeltaEventArgs> DragDelta;
event Windows.Foundation.TypedEventHandler<ResizeGripper, Object> DragStarted;
event Windows.Foundation.TypedEventHandler<ResizeGripper, ResizeGripperDragDeltaEventArgs> DragDelta;
event Windows.Foundation.TypedEventHandler<ResizeGripper, ResizeGripperDragCompletedEventArgs> DragCompleted;
static Microsoft.UI.Xaml.DependencyProperty IsDraggingProperty{ get; };
@@ -91,6 +54,6 @@ unsealed runtimeclass ResizeGripper : Microsoft.UI.Xaml.Controls.Control
static Microsoft.UI.Xaml.DependencyProperty OwnerNameProperty{ get; };
static Microsoft.UI.Xaml.DependencyProperty DragOrientationProperty{ get; };
static Microsoft.UI.Xaml.DependencyProperty KeyboardIncrementProperty{ get; };
};
}
}
}
@@ -1,5 +1,4 @@
// Peer for the framework-internal ResizeGripper primitive, so it lives in the
// same private namespace as its owner and never reaches a shipped WinMD.
// Do not move out of the private namespace: it, not [MUX_INTERNAL], keeps these types out of shipped winmds.
namespace MU_PRIVATE_CONTROLS_NAMESPACE
{
@@ -7,7 +6,8 @@ namespace MU_PRIVATE_CONTROLS_NAMESPACE
[webhosthidden]
unsealed runtimeclass ResizeGripperAutomationPeer : Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer
{
[method_name("CreateInstanceWithOwner")] ResizeGripperAutomationPeer(ResizeGripper owner);
[method_name("CreateInstanceWithOwner")]
ResizeGripperAutomationPeer(ResizeGripper owner);
}
}
+115 -412
View File
@@ -1,7 +1,6 @@
namespace MU_XC_NAMESPACE
{
// Column pinning edge; None scrolls naturally, Leading pins left, Trailing is reserved.
[MUX_PREVIEW]
[webhosthidden]
enum TableViewFrozenEdge
@@ -11,8 +10,6 @@ enum TableViewFrozenEdge
Trailing = 2,
};
// Only None and Single this release. Multiple/Extended need an anchor and a range model; members
// are appended, so these values stay stable when they arrive.
[MUX_PREVIEW]
[webhosthidden]
enum TableViewSelectionMode
@@ -21,8 +18,6 @@ enum TableViewSelectionMode
Single = 1,
};
// Column-header visibility (None/Column). Mirrors the Column slot of WPF
// DataGridHeadersVisibility; row headers are out of scope, so Row/All are not defined.
[MUX_PREVIEW]
[webhosthidden]
[flags]
@@ -32,7 +27,6 @@ enum TableViewHeadersVisibility
Column = 1,
};
// Mirrors WPF DataGridGridLinesVisibility names and values for persisted casts.
[MUX_PREVIEW]
[webhosthidden]
enum TableViewGridLinesVisibility
@@ -43,7 +37,6 @@ enum TableViewGridLinesVisibility
Vertical = 3,
};
// Density preset for row height and built-in cell/header padding resources.
[MUX_PREVIEW]
[webhosthidden]
enum TableViewDensity
@@ -53,8 +46,6 @@ enum TableViewDensity
Comfortable = 2,
};
// How an edit closed. A forced close reports Cancel, writing nothing, when the item was removed,
// filtered out, or ItemsSource was replaced; an item still present commits a valid pending value.
[MUX_PREVIEW]
[webhosthidden]
enum TableViewEditAction
@@ -63,11 +54,6 @@ enum TableViewEditAction
Cancel = 1,
};
// Direction sequence a column's header walks on repeated clicks. The first pair member is the
// direction an unsorted column opens in: Ascending* suits names and categories, Descending* suits
// metrics where the interesting rows are the largest (CPU, memory, size). The *None variants add a
// trailing unsorted step, so a third click returns the column to source order instead of going
// straight back to the opening direction.
[MUX_PREVIEW]
[webhosthidden]
enum TableViewSortCycle
@@ -78,10 +64,6 @@ enum TableViewSortCycle
DescendingAscendingNone = 3,
};
// Orders two row items. Return <0, 0 or >0 like a classic three-way comparison. The comparer must
// be a pure function of its inputs: re-entering the control from it is an app bug.
// An interface rather than a delegate so a comparer can be declared as a XAML resource and assigned
// in markup.
[MUX_PREVIEW]
[webhosthidden]
interface ITableViewSortComparer
@@ -89,43 +71,29 @@ interface ITableViewSortComparer
Int32 Compare(Object left, Object right);
};
// Item-owned edit transaction (the WinRT counterpart of IEditableObject) is deliberately NOT in
// this release. It is the rollback mechanism for ROW-scoped editing, and editing is cell-scoped
// here, so shipping it now would be public surface with nothing behind it. It lands with row
// editing, when it has a job to do.
[MUX_PREVIEW]
[webhosthidden]
[contentproperty("Header")]
[MUX_PROPERTY_CHANGED_CALLBACK_METHODNAME("OnPropertyChanged")]
unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
{
// Required for derivation: the overridable cell factories below are only reachable if a
// consumer can actually construct a derived column.
TableViewColumn();
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Object Header { get; set; };
// Optional header template; HeaderTemplateSelector takes precedence.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.DataTemplate HeaderTemplate { get; set; };
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.Controls.DataTemplateSelector HeaderTemplateSelector { get; set; };
// Opt-in header tooltip content; null or empty means no tooltip. Not a Binding property: a
// header is not bound against a row, so there is nothing to defer.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Object HeaderToolTip { get; set; };
// Style for this column's TableViewColumnHeader; wins over TableView.ColumnHeaderStyle.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.Style HeaderStyle { get; set; };
// Width carries GridLength intent; ActualWidth is the resolved, MinWidth/MaxWidth-clamped
// pixels (see TableView_Layout.cpp). Pixel = exact; Auto = widest realized cell (grows within a
// data set); Star = a proportional share of the body viewport after fixed columns.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("c_widthDefault")]
Microsoft.UI.Xaml.GridLength Width { get; set; };
@@ -138,18 +106,14 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
[MUX_DEFAULT_VALUE("std::numeric_limits<double>::infinity()")]
Double MaxWidth { get; set; };
// Gates the resize affordance for this column only; the owner's CanUserResizeColumns gates all of them.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_PROPERTY_CHANGED_CALLBACK_METHODNAME("OnPropertyChanged")]
[MUX_DEFAULT_VALUE("true")]
Boolean CanResize { get; set; };
// Read-only DP written by the column's width resolve; the header host and realized cell panels
// arrange each cell to this width (no per-cell binding).
[MUX_DEFAULT_VALUE("120.0")]
Double ActualWidth { get; };
// Pins the column during horizontal scroll; Leading is implemented, Trailing reserved.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("winrt::TableViewFrozenEdge::None")]
MU_XC_NAMESPACE.TableViewFrozenEdge FrozenEdge { get; set; };
@@ -158,82 +122,34 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
[MUX_DEFAULT_VALUE("winrt::Visibility::Visible")]
Microsoft.UI.Xaml.Visibility Visibility { get; set; };
// Per-column sort opt-out (default true). False blocks header clicks, SortByColumn (except None) and
// ToggleSortDirection; setting it false on the sorted column clears that sort.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("true")]
Boolean CanSort { get; set; };
// Direction sequence repeated header clicks walk for THIS column. Per-column because the cycle
// describes how one column responds to being clicked again, and a table can reasonably mix
// policies - a metric column that should open on its largest values alongside a name column
// that should open A-Z. Read at click time, so a change needs no rebuild.
[MUX_DEFAULT_VALUE("winrt::TableViewSortCycle::AscendingDescending")]
TableViewSortCycle SortCycle { get; set; };
// Property name on the row item used to extract the sort key. When empty, TableViewTextColumn
// falls back to its Binding.Path.Path; the base column returns an empty string and the column
// is only sortable if it supplies a CustomSortComparer.
//
// The path need not be unique. Several columns may name the same property - a common shape when
// one column formats a value and another ranks it. Each column sorts as its own independent
// axis, keyed on column identity rather than on this path, so duplicates neither collide nor
// prevent the table from being built. Sorting one of them leaves the others' SortDirection at
// None, since sorting is single-column: applying a sort clears any previous one.
String SortMemberPath { get; set; };
// Optional comparer supplied by the column for custom sort ordering. Takes precedence over
// SortMemberPath. With TableViewSource.IsLiveShaping, changed items are re-compared and moved.
ITableViewSortComparer CustomSortComparer { get; set; };
// Read-only DP driven by the owning TableView. None means the column is not part of the
// active sort. Apps change it through the TableView sort methods or TableViewSource.Sort.
[MUX_DEFAULT_VALUE("winrt::SortDirection::None")]
SortDirection SortDirection { get; };
// Returns the effective sort key path. The base column returns SortMemberPath verbatim;
// TableViewTextColumn overrides to fall back to Binding.Path.Path when SortMemberPath is empty.
overridable String GetSortMemberPathCore();
// Generates the display-only visual root for a cell.
Microsoft.UI.Xaml.FrameworkElement GenerateElement(Object dataItem);
// Overridable cell factory. Custom columns return the display visual for a cell.
// The returned element must bind reactively to its inherited DataContext (the row data item) and
// must not set a local DataContext: rows are recycled and their DataContext is updated in place,
// so cells refresh through inheritance/bindings. dataItem is provided for initial setup only;
// baking it in as static content (or a local DataContext) will show stale data after recycle.
overridable Microsoft.UI.Xaml.FrameworkElement GenerateElementCore(Object dataItem);
// Per-column opt-out. A read-only column can still be the current cell, but cannot be edited.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("false")]
Boolean IsReadOnly { get; set; };
// Editing visual. A column with no CellEditingTemplate and no built-in editor is not editable.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.DataTemplate CellEditingTemplate { get; set; };
// Opt-in per-cell tooltip content, bound against the row's data item; null or empty means no
// tooltip. A CLR property, not a DP, so XAML passes the Binding through unevaluated.
Microsoft.UI.Xaml.Data.Binding CellToolTipBinding;
// Shown at the cell, with the row's item as its items' DataContext; shared by the column's cells.
// First match wins: a ContextFlyout inside the cell (not on a nested items host's items or
// anywhere in a nested TableView; on text controls only one set on the element itself),
// CellContextFlyout, the row's ContextFlyout, then TableView.ContextFlyout. The cell takes
// focus; selection does not change. No TableView menu opens while a cell is editing. Group
// headers never use it.
Microsoft.UI.Xaml.Controls.Primitives.FlyoutBase CellContextFlyout { get; set; };
// Shown at the header cell, after a Style/HeaderTemplate ContextFlyout and before
// TableView.ContextFlyout. Not opened for collapsed columns or while editing.
Microsoft.UI.Xaml.Controls.Primitives.FlyoutBase HeaderContextFlyout { get; set; };
// The editing hooks a derived column would override - GenerateEditingElement, PrepareCellForEdit,
// CommitCellEdit, CancelCellEdit - are internal in this release. The built-in columns use them,
// but a CellEditingTemplate is the supported way to customise an editor here, so publishing the
// hooks now would fix their exact shape forever. They can be added later without breaking anyone.
overridable String GetSortMemberPathCore();
Microsoft.UI.Xaml.FrameworkElement GenerateElement(Object dataItem);
overridable Microsoft.UI.Xaml.FrameworkElement GenerateElementCore(Object dataItem);
static Microsoft.UI.Xaml.DependencyProperty HeaderProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty HeaderTemplateProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty HeaderTemplateSelectorProperty { get; };
@@ -246,47 +162,37 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
static Microsoft.UI.Xaml.DependencyProperty ActualWidthProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty FrozenEdgeProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty VisibilityProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty IsReadOnlyProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty CellEditingTemplateProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty CellContextFlyoutProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty HeaderContextFlyoutProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty CanSortProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty SortCycleProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty SortMemberPathProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty SortDirectionProperty { get; };
};
// ----- Sort event args -----
static Microsoft.UI.Xaml.DependencyProperty IsReadOnlyProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty CellEditingTemplateProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty CellContextFlyoutProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty HeaderContextFlyoutProperty { get; };
}
[MUX_PREVIEW]
[webhosthidden]
runtimeclass TableViewSortingEventArgs
unsealed runtimeclass TableViewTextColumn : TableViewColumn
{
// The trigger column. Null is the clear-all sentinel used by ClearSort.
MU_XC_NAMESPACE.TableViewColumn Column { get; };
TableViewTextColumn();
// The direction the control is about to apply.
SortDirection Direction { get; };
// Set true to stop the control applying the sort, leaving the app to own the ordering. The
// control's sort state, glyph and Sorted are suppressed; TableViewSource.Sort can light the glyph.
Boolean Cancel;
};
Microsoft.UI.Xaml.Data.Binding Binding;
}
[MUX_PREVIEW]
[webhosthidden]
runtimeclass TableViewSortedEventArgs
[MUX_PROPERTY_CHANGED_CALLBACK_METHODNAME("OnPropertyChanged")]
unsealed runtimeclass TableViewTemplateColumn : TableViewColumn
{
// The trigger column for this sort-state change. Null is the clear-all sentinel.
MU_XC_NAMESPACE.TableViewColumn Column { get; };
TableViewTemplateColumn();
// The direction now applied to the column.
SortDirection Direction { get; };
};
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.DataTemplate CellTemplate { get; set; };
// ----- Editing event args -----
// Cancel is read synchronously, right after the handler returns, so a handler must decide before
// returning. Asynchronous validation would need a deferral, which is not in this release.
static Microsoft.UI.Xaml.DependencyProperty CellTemplateProperty { get; };
}
[MUX_PREVIEW]
[webhosthidden]
@@ -295,7 +201,7 @@ runtimeclass TableViewBeginningEditEventArgs
Object Item { get; };
MU_XC_NAMESPACE.TableViewColumn Column { get; };
Boolean Cancel;
};
}
[MUX_PREVIEW]
[webhosthidden]
@@ -304,63 +210,24 @@ runtimeclass TableViewCellEditEndingEventArgs
Object Item { get; };
MU_XC_NAMESPACE.TableViewColumn Column { get; };
MU_XC_NAMESPACE.TableViewEditAction EditAction { get; };
Boolean Cancel;
};
}
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewTextColumn : TableViewColumn
runtimeclass TableViewSortingEventArgs
{
TableViewTextColumn();
MU_XC_NAMESPACE.TableViewColumn Column { get; };
SortDirection Direction { get; };
Boolean Cancel;
}
// CLR property so Binding-typed XAML values route through the setter.
Microsoft.UI.Xaml.Data.Binding Binding;
};
/// A column whose display visuals are consumer-defined via DataTemplates.
[MUX_PREVIEW]
[webhosthidden]
[MUX_PROPERTY_CHANGED_CALLBACK_METHODNAME("OnPropertyChanged")]
unsealed runtimeclass TableViewTemplateColumn : TableViewColumn
runtimeclass TableViewSortedEventArgs
{
TableViewTemplateColumn();
/// Template used to generate display cell content.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.DataTemplate CellTemplate { get; set; };
static Microsoft.UI.Xaml.DependencyProperty CellTemplateProperty { get; };
};
// Cell layout host used as PART_HeaderHost and each row's PART_CellsHost. Measures cells for Auto
// size-to-content and arranges them at each column's resolved ActualWidth (see TableViewCellsPanel.cpp
// and TableView_Layout.cpp).
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewCellsPanel : Microsoft.UI.Xaml.Controls.Panel
{
TableViewCellsPanel();
};
// Read-only group-header projection: the binding source of a GroupHeaderTemplate.
// Mirrors ICollectionViewGroup (Key <-> Group, ItemCount <-> GroupItems.Count).
// Updated in place and raises PropertyChanged, so recycling does not re-evaluate
// every binding in the header template.
[MUX_PREVIEW]
[webhosthidden]
runtimeclass TableViewGroupInfo : Microsoft.UI.Xaml.Data.INotifyPropertyChanged
{
Object Key { get; }; // the GroupBy key
Int32 ItemCount { get; }; // members in the group
Int32 Level { get; }; // 0 for single-level v1
Boolean IsExpandable { get; }; // false when the group can't expand (e.g. empty)
Boolean IsExpanded { get; };
// Culture-formatted display strings for the built-in content template.
// Computed lazily: a template binding only Key / ItemCount pays nothing.
String KeyText { get; };
String ItemCountText { get; };
MU_XC_NAMESPACE.TableViewColumn Column { get; };
SortDirection Direction { get; };
}
[MUX_PREVIEW]
@@ -368,55 +235,16 @@ runtimeclass TableViewGroupInfo : Microsoft.UI.Xaml.Data.INotifyPropertyChanged
[webhosthidden]
runtimeclass TableViewGroupHeaderToggleRequestedEventArgs
{
// Carried on the args so a handler that re-enters and mutates the header
// still sees the key that was actually activated.
Object GroupKey { get; };
}
// A templated ContentControl rather than a code-built visual tree: the chrome and
// expander live in one control-owned ControlTemplate, and GroupHeaderTemplate fills
// only the content region, so it cannot drop the expander or the themed band.
// Replacing Style/Template outright still hands the app the whole visual -- the usual
// re-template contract, which is why the parts are documented here.
//
// The whole band is the toggle target (matching ListView / TreeView) rather than a
// nested Button, so there is one interactive element and one automation story.
//
// Parts: PART_Root, PART_ContentPresenter, PART_ExpanderIcon (targeted by the default states), PART_ExpanderGutter;
// optional PART_GridLineBorder.
//
// States: CommonStates Normal|PointerOver|Pressed|Disabled
// ExpansionStates Expanded|Collapsed
// ExpandabilityStates Expandable|NotExpandable
[MUX_PREVIEW]
[webhosthidden]
[MUX_PROPERTY_CHANGED_CALLBACK_METHODNAME("OnPropertyChanged")]
unsealed runtimeclass TableViewGroupHeader : Microsoft.UI.Xaml.Controls.ContentControl
unsealed runtimeclass TableViewCellsPanel : Microsoft.UI.Xaml.Controls.Panel
{
TableViewGroupHeader();
// Content is the TableViewGroupInfo projection, and it is also the header's DataContext, so
// bindings in a group header Style or its ContextFlyout see it. ContentTemplate is the header content
// template. The default Style seeds it, and TableView.GroupHeaderTemplate overrides it as a
// local value that ClearValue reverts back to the Style setter.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Boolean IsExpanded { get; set; };
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Boolean IsExpandable { get; set; };
// The owning row forwards this to TableView::ToggleGroupExpansion, keeping the header
// free of TableView plumbing and independently testable.
event Windows.Foundation.TypedEventHandler<TableViewGroupHeader, TableViewGroupHeaderToggleRequestedEventArgs> ToggleRequested;
static Microsoft.UI.Xaml.DependencyProperty IsExpandedProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty IsExpandableProperty { get; };
TableViewCellsPanel();
}
// Templated header, one per column, created only by TableView; restyled via TableView.ColumnHeaderStyle
// or TableViewColumn.HeaderStyle.
// Optional parts: PART_ContentPresenter, PART_GridLine, PART_SortIndicatorHost, PART_ResizeGripperHost.
[MUX_PREVIEW]
[webhosthidden]
runtimeclass TableViewColumnHeader : Microsoft.UI.Xaml.Controls.ContentControl
@@ -426,59 +254,19 @@ runtimeclass TableViewColumnHeader : Microsoft.UI.Xaml.Controls.ContentControl
[MUX_PREVIEW]
[webhosthidden]
//
// Template contract (MIDL3 does not surface [TemplatePart], so this block is it).
// The first four parts are REQUIRED: the Selected* states target them by name.
//
// PART_RootBorder — Border. Row chrome; Background driven by CommonStates.
// PART_GridLineBorder — Border. Renders border chrome and terminal-line suppression.
// PART_CellsHost — Panel. One cell wrapper per column, in Columns order.
// PART_CellForegroundPresenter — ContentPresenter hosting PART_CellsHost. Exists because a
// Border has no Foreground for the Selected* states to animate.
// PART_SelectionIndicator — Shape (default: Rectangle). Selected* states animate Opacity
// 0 -> 1; SelectedDisabled also animates Fill. Custom templates
// using other indicator types must adapt the state targets.
//
// The hierarchy parts are OPTIONAL, but declared together with the three hierarchy state groups.
//
// PART_RowExpanderGutter — FrameworkElement. Chevron column and toggle target; Margin set
// in code to the row's indent.
// PART_RowExpanderIcon — FontIcon. Glyph and Visibility driven by the state groups.
//
// CommonStates: Normal, PointerOver, Pressed, Disabled,
// Selected, SelectedPointerOver, SelectedPressed, SelectedDisabled
// RowHierarchyStates: RowHierarchical, RowNotHierarchical
// RowExpandabilityStates: RowExpandable, RowNotExpandable
// RowExpansionStates: RowExpanded, RowCollapsed
//
// The hierarchy groups are entered on every row, including in flat and grouped tables. Names are
// Row-prefixed so they cannot collide with an app's own Expanded/Collapsed states in a retemplate.
//
// Selected states share this one group so nothing depends on GoToState call order.
// Multiple/Extended selection will add a separate MultiSelectStates group (and a checkbox part)
// rather than extending this one, so it stays additive for existing templates.
//
unsealed runtimeclass TableViewRow : Microsoft.UI.Xaml.Controls.Control
{
TableViewRow();
// Read-only DP written only by the owning TableView (Register + a setter that is not
// projected, NOT RegisterReadOnly). Drives the Selected* states.
[MUX_DEFAULT_VALUE("false")]
Boolean IsSelected { get; };
// ----- Hierarchy -----
// Read-only DPs written by the owning TableView. All 0/false for flat and grouped sources.
// 1-based depth (roots are 1), matching AutomationProperties.Level. 0 = not hierarchical.
[MUX_DEFAULT_VALUE("0")]
Int32 Level { get; };
// True when this row has children. Drives RowExpandabilityStates.
[MUX_DEFAULT_VALUE("false")]
Boolean IsExpandable { get; };
// True when this row's children are currently projected as rows beneath it.
[MUX_DEFAULT_VALUE("false")]
Boolean IsExpanded { get; };
@@ -486,25 +274,50 @@ unsealed runtimeclass TableViewRow : Microsoft.UI.Xaml.Controls.Control
static Microsoft.UI.Xaml.DependencyProperty LevelProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty IsExpandableProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty IsExpandedProperty { get; };
};
}
[MUX_INTERNAL]
[webhosthidden]
unsealed runtimeclass TableViewRowTemplateSelector : Microsoft.UI.Xaml.Controls.DataTemplateSelector
{
TableViewRowTemplateSelector();
}
[MUX_PREVIEW]
[webhosthidden]
runtimeclass TableViewGroupInfo : Microsoft.UI.Xaml.Data.INotifyPropertyChanged
{
Object Key { get; };
Int32 ItemCount { get; };
Int32 Level { get; };
Boolean IsExpandable { get; };
Boolean IsExpanded { get; };
String KeyText { get; };
String ItemCountText { get; };
}
[MUX_PREVIEW]
[webhosthidden]
[MUX_PROPERTY_CHANGED_CALLBACK_METHODNAME("OnPropertyChanged")]
unsealed runtimeclass TableViewGroupHeader : Microsoft.UI.Xaml.Controls.ContentControl
{
TableViewGroupHeader();
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Boolean IsExpanded { get; set; };
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Boolean IsExpandable { get; set; };
event Windows.Foundation.TypedEventHandler<TableViewGroupHeader, TableViewGroupHeaderToggleRequestedEventArgs> ToggleRequested;
static Microsoft.UI.Xaml.DependencyProperty IsExpandedProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty IsExpandableProperty { get; };
}
[MUX_PREVIEW]
[webhosthidden]
[contentproperty("Columns")]
//
// Template contract: re-templates must provide these named parts.
// Missing required parts disables the related feature.
//
// PART_HeaderRow — FrameworkElement. Collapsed when headers are hidden.
// PART_HeaderHost — Panel. Receives one header cell per column.
// PART_RowsRepeater — ItemsRepeater. Hosts row containers; do not TemplateBind ItemsSource.
// PART_EmptyStatePresenter — ContentControl/ContentPresenter (optional). Hosts EmptyTemplate.
//
// Structure, not named parts: the header and body scrollers are the nearest ScrollViewer ancestors
// of PART_HeaderHost / PART_RowsRepeater inside the template; their names are not looked up.
//
// Unsealed: ReferenceTracker requires the cppwinrt-projected derivable type surface.
//
unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
{
TableView();
@@ -512,162 +325,77 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Object ItemsSource { get; set; };
// ABI exposes IVector, but the backing vector is observable for live column updates.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Windows.Foundation.Collections.IVector<TableViewColumn> Columns { get; };
// Controls column-header visibility without mutating Columns.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("winrt::TableViewHeadersVisibility::Column")]
TableViewHeadersVisibility HeadersVisibility { get; set; };
// Body row dividers and column separators. Header lines are always drawn.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("winrt::TableViewGridLinesVisibility::All")]
TableViewGridLinesVisibility GridLinesVisibility { get; set; };
// Master switch for the column-resize affordance; a column opts out individually via CanResize.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("true")]
Boolean CanUserResizeColumns { get; set; };
// Opt-in row banding; null brushes preserve the theme row background.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.Media.Brush RowBackground { get; set; };
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.Media.Brush AlternatingRowBackground { get; set; };
// Optional empty-state template shown when ItemsSource is null or empty.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.DataTemplate EmptyTemplate { get; set; };
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.DataTemplate GroupHeaderTemplate { get; set; };
// Style for every TableViewColumnHeader; TableViewColumn.HeaderStyle overrides it per column.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
Microsoft.UI.Xaml.Style ColumnHeaderStyle { get; set; };
// Vertical density preset for row height and built-in cell/header padding.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("winrt::TableViewDensity::Standard")]
TableViewDensity Density { get; set; };
// Editing is opt-in: while true (the default) no cell can be edited, whatever the column says.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("true")]
Boolean IsReadOnly { get; set; };
// ----- Editing -----
// True while an edit is in flight on a cell: from the edit being accepted, through the open
// editor, until the matching close completes.
//
// It also covers the brief windows where an edit is still opening and where a close is already
// underway, because the control uses this to reject re-entrant edit operations from inside
// consumer callbacks. An app querying it from a BeginningEdit handler therefore sees true even
// if that handler goes on to cancel.
//
// Cell scope: this is "a cell editor is open". Row-scoped editing is not part of this release.
Boolean IsEditing { get; };
// Editing starts from the user: double-click, or F2 on the current cell. There is no
// programmatic BeginEdit in this release, and therefore no public current-cell surface for one
// to target. The context-menu feature keeps current-cell access internal; a public command
// surface still arrives later, when there is a programmatic editing scenario to justify it.
// Closes the open CELL edit. False when it did not close: a veto or failed validation leaves the
// edit open and IsEditing true.
//
// There is no row-scoped commit/cancel API and no RowEditEnding in this release; both arrive
// with row editing.
Boolean CommitEdit();
Boolean CancelEdit();
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, MU_XC_NAMESPACE.TableViewBeginningEditEventArgs> BeginningEdit;
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, MU_XC_NAMESPACE.TableViewCellEditEndingEventArgs> CellEditEnding;
// There is no post-close event in this release. CellEditEnding reports the outcome through
// EditAction and is raised for every close, so a second "it finished" event is surface without
// a scenario of its own yet. It can be added later without breaking anyone.
// ----- Selection -----
// Row-scoped, single item. SelectedItem and SelectedIndex are read-only projections of the
// selection and stay coherent with each other. Drive selection through Select / Deselect /
// DeselectAll, matching ItemsView. Independent of editing.
// On by default, matching ItemsView, ListView and WPF's DataGrid. None = display-only.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("winrt::TableViewSelectionMode::Single")]
TableViewSelectionMode SelectionMode { get; set; };
// The selected data item, or null.
Object SelectedItem { get; };
// The selected item's index in the displayed row projection, or -1 when nothing is selected.
// Under grouping, group headers occupy projection indexes but cannot be selected.
[MUX_DEFAULT_VALUE("-1")]
Int32 SelectedIndex { get; };
// Named to match ItemsView. No identity-based overloads: an index is the only addressing mode.
void Select(Int32 index);
void Deselect(Int32 index);
Boolean IsSelected(Int32 index);
void DeselectAll();
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs> SelectionChanged;
// ----- Bulk expansion commands -----
// Expands or collapses every group in one batch. Individual groups are expanded and collapsed
// through the group header's ExpandCollapse pattern or by clicking the header itself; these
// are the programmatic bulk counterparts. Both are no-ops when the source is not grouped.
// Under GroupBy + ParentBy these affect only group headers, not tree nodes.
void ExpandAllGroups();
void CollapseAllGroups();
// Expands or collapses every tree node of a TableViewSource.ParentBy projection; no-ops
// otherwise. Independent of the group verbs. Sets the remembered state of every node, so the
// result persists after the filter changes or clears.
void ExpandAllRows();
void CollapseAllRows();
// ----- Sorting -----
// Single-column sort. The control reshapes the rows itself: an ItemsSource that is not already
// a TableViewSource is projected through one internally, so sorting works over any collection
// the control can display. Sorting is raised first; cancel it to own the ordering instead.
// The active sort state lives on the column, as TableViewColumn.SortDirection; Sorted hands
// the app the column that changed, and a null column there means the sort was cleared.
//
// This and TableViewSource.Sort reconcile rather than stack: exactly one sort axis is ever in
// force, and the last writer wins. See the dev spec for the full ownership rules.
// Control-wide gate for the click-to-sort UX. When false no sort affordance is built and
// header clicks do not sort; programmatic sorting still works. The per-column cycle policy is
// TableViewColumn.SortCycle.
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
[MUX_DEFAULT_VALUE("true")]
Boolean CanUserSortColumns { get; set; };
// Clears any other sort state, then sets this column's SortDirection. Pass SortDirection.None
// to clear. Returns true when the sort state changed; returns false when the sort could not be
// applied (for example, the column cannot sort). Clearing with None works even when CanSort is false.
Boolean CommitEdit();
Boolean CancelEdit();
void Select(Int32 index);
void Deselect(Int32 index);
Boolean IsSelected(Int32 index);
void DeselectAll();
void ExpandAllGroups();
void CollapseAllGroups();
void ExpandAllRows();
void CollapseAllRows();
Boolean SortByColumn(MU_XC_NAMESPACE.TableViewColumn column, SortDirection direction);
// Cycles the trigger column's direction per SortCycle, clearing any other sort state first.
// Returns true when the sort state changed; returns false when the sort could not be applied
// (for example, when the rows cannot be ordered, or the column cannot sort, as for SortByColumn).
Boolean ToggleSortDirection(MU_XC_NAMESPACE.TableViewColumn column);
// Clears every column's sort state in one batch. Returns true when anything was cleared.
Boolean ClearSort();
// Raised BEFORE a sort-state change is applied. Set Cancel=true to keep the control's sort
// state unchanged and let the app own the ordering.
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, MU_XC_NAMESPACE.TableViewBeginningEditEventArgs> BeginningEdit;
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, MU_XC_NAMESPACE.TableViewCellEditEndingEventArgs> CellEditEnding;
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs> SelectionChanged;
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, MU_XC_NAMESPACE.TableViewSortingEventArgs> Sorting;
// Raised AFTER a sort-state change has been applied. Not cancellable.
event Windows.Foundation.TypedEventHandler<MU_XC_NAMESPACE.TableView, MU_XC_NAMESPACE.TableViewSortedEventArgs> Sorted;
static Microsoft.UI.Xaml.DependencyProperty ItemsSourceProperty { get; };
@@ -686,7 +414,7 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
static Microsoft.UI.Xaml.DependencyProperty SelectedItemProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty SelectedIndexProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty CanUserSortColumnsProperty { get; };
};
}
[MUX_PREVIEW]
[webhosthidden]
@@ -700,60 +428,6 @@ unsealed runtimeclass TableViewAutomationPeer
TableViewAutomationPeer(MU_XC_NAMESPACE.TableView owner);
}
// ExpandCollapse is advertised only by rows of a hierarchical source.
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewRowAutomationPeer
: Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer,
Microsoft.UI.Xaml.Automation.Provider.ISelectionItemProvider,
Microsoft.UI.Xaml.Automation.Provider.IExpandCollapseProvider
{
TableViewRowAutomationPeer(MU_XC_NAMESPACE.TableViewRow owner);
}
// Picks the row container template from TableViewRowKind. Set as PART_RowsRepeater.ItemTemplate,
// so ItemsRepeater supplies the per-template recycle pools. Projected only because
// ItemTemplateWrapper lives in another DLL; not app-facing, since selection needs an owning
// TableView that only TableView can set. unsealed + ctor is what MIDL requires for a member-less
// runtimeclass.
[MUX_INTERNAL]
[webhosthidden]
unsealed runtimeclass TableViewRowTemplateSelector
: Microsoft.UI.Xaml.Controls.DataTemplateSelector
{
TableViewRowTemplateSelector();
}
// Automation peer for the group-header band. Separate from TableViewRowAutomationPeer because
// the header is now its own container rather than a row in "group mode": there is no adaptive
// branch, no GridItem coordinates for a band that spans every column, and ExpandCollapse is
// unconditional (a non-expandable group reports LeafNode rather than dropping the pattern).
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewGroupHeaderAutomationPeer
: Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer,
Microsoft.UI.Xaml.Automation.Provider.IExpandCollapseProvider,
Microsoft.UI.Xaml.Automation.Provider.IGridItemProvider
{
TableViewGroupHeaderAutomationPeer(MU_XC_NAMESPACE.TableViewGroupHeader owner);
}
// Peer of a TableViewColumnHeader and of the TableView-owned header entries.
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewColumnHeaderAutomationPeer
: Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer,
Microsoft.UI.Xaml.Automation.Provider.IInvokeProvider
{
TableViewColumnHeaderAutomationPeer(MU_XC_NAMESPACE.TableView owner, MU_XC_NAMESPACE.TableViewColumn column);
}
// Cell peer supplies per-cell grid/table structure. Its name is the cell value ("blank" when empty);
// the column header is reported through ITableItemProvider.GetColumnHeaderItems while column headers
// are shown and the column is visible. Only when column headers are hidden does the name become
// "{column header}, {cell value}". A cell holding an interactive control is named after its column
// header, since the control names itself.
// The constructor throws E_INVALIDARG for null arguments, a negative columnIndex, or a column/index outside the owning table.
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewCellAutomationPeer
@@ -765,4 +439,33 @@ unsealed runtimeclass TableViewCellAutomationPeer
TableViewCellAutomationPeer(Microsoft.UI.Xaml.FrameworkElement cell, MU_XC_NAMESPACE.TableViewRow row, MU_XC_NAMESPACE.TableViewColumn column, Int32 columnIndex);
}
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewColumnHeaderAutomationPeer
: Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer,
Microsoft.UI.Xaml.Automation.Provider.IInvokeProvider
{
TableViewColumnHeaderAutomationPeer(MU_XC_NAMESPACE.TableView owner, MU_XC_NAMESPACE.TableViewColumn column);
}
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewRowAutomationPeer
: Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer,
Microsoft.UI.Xaml.Automation.Provider.ISelectionItemProvider,
Microsoft.UI.Xaml.Automation.Provider.IExpandCollapseProvider
{
TableViewRowAutomationPeer(MU_XC_NAMESPACE.TableViewRow owner);
}
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewGroupHeaderAutomationPeer
: Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer,
Microsoft.UI.Xaml.Automation.Provider.IExpandCollapseProvider,
Microsoft.UI.Xaml.Automation.Provider.IGridItemProvider
{
TableViewGroupHeaderAutomationPeer(MU_XC_NAMESPACE.TableViewGroupHeader owner);
}
}
+5 -95
View File
@@ -9,124 +9,34 @@ delegate Boolean TableViewPredicate(Object item);
[webhosthidden]
delegate Object TableViewKeySelector(Object item);
// Stable string identity for a group key (GroupBy overload). Row identity is not app-supplied:
// the projection derives it from each item's object identity.
[MUX_PREVIEW]
[webhosthidden]
delegate String TableViewIdentitySelector(Object item);
[MUX_PREVIEW]
[webhosthidden]
// Sealed deliberately: TableView drives the concrete implementation, so an override of a shaping
// verb would be bypassed. Unsealing later is compatible; sealing later is not.
runtimeclass TableViewSource
{
// Whole class:
// * Null items, predicate, or keySelector throws E_INVALIDARG. (Note this differs from
// TableView's command surface, which no-ops on bad input.) The optional
// groupIdentitySelector may be null and selects the built-in value-type group identity.
// * UI-thread affinity, like any XAML items source. The underlying collection must also
// raise its change notifications on that thread; raising them from a background thread
// is not supported. The verbs hold no lock.
// * Rows are identified by their item's OBJECT identity (under ParentBy, by its node key,
// which also survives re-creation). So selection and focus re-anchor across a reshape, but
// NOT across an item being re-created, and the same object in two rows fails fast at
// materialization.
// * A TableViewSource can be the ItemsSource of one TableView at a time. A second table it is
// bound to is refused and renders empty until the owner releases it (assigns a different
// ItemsSource or is unloaded), then binds it. The refusal is not an error: nothing throws.
// An unloaded owner that is loaded again reacquires the source automatically, or waits for
// it if another table took it meanwhile.
Boolean IsLiveShaping { get; set; };
// Accepts the same collection interfaces as ItemsSourceView: IVector<Object> (so also
// IObservableVector<Object>), IBindableVector, IIterable<Object>, or IBindableIterable.
// Anything else throws E_INVALIDARG. The projection is populated by the time From returns.
static TableViewSource From(Object items);
// Use ClearFilter() to remove an existing filter rather than passing a pass-everything one.
TableViewSource Filter(TableViewPredicate predicate);
// keySelector: required, non-null (throws E_INVALIDARG when null; use ClearGroupBy() to
// remove grouping). Overload metadata is frozen at v1 — without it MIDL bakes GroupBy2 into
// the ABI.
//
// Grouping FAILS FAST. When a group's identity cannot be resolved, the call that builds the
// projection throws E_INVALIDARG; the projection is never silently flattened, because a
// grouping request that quietly renders ungrouped is a bug an app ships without noticing.
// An identity is unresolvable when:
// * the key is null, or is a reference type and no groupIdentitySelector was supplied;
// * the key is the EMPTY STRING, or a supplied groupIdentitySelector returns the empty
// string. String is a supported key type, but "" is not a usable identity, so an app
// grouping on a property that can be null/blank must coalesce it to a real bucket label
// (e.g. "(none)") itself;
// * a supplied groupIdentitySelector throws;
// * two logically-different keys resolve to the same identity string and no
// groupIdentitySelector was supplied to declare that collapse intentional.
// Do not remove the [default_overload]/[method_name] metadata on GroupBy/Sort: it is frozen ABI, and
// without it MIDL renames the overloads (GroupBy2/Sort2).
[default_overload] [method_name("GroupBy")]
TableViewSource GroupBy(TableViewKeySelector keySelector);
// groupIdentitySelector is OPTIONAL: null selects the built-in value-type group identity
// (String/Int32/Int64/Guid/Boolean/enum); a reference-type group key with no selector fails
// fast at projection time, as does an empty identity from either source (see above).
[method_name("GroupByWithIdentity")]
TableViewSource GroupBy(TableViewKeySelector keySelector, TableViewIdentitySelector groupIdentitySelector);
// SortDirection.None removes that key's sort axis rather than seeding a stage, so sorting
// every axis to None leaves the source unsorted. When several axes are active the FIRST one
// declared is the primary sort and each later axis breaks ties within the previous, matching
// WPF DataGrid's SortDescriptions order; re-sorting an existing axis keeps its position.
//
// Both overloads are LAST WRITER WINS against TableView's own sort: declaring a sort here
// replaces the control's axis rather than stacking behind it, so the rows are ordered by this
// axis alone. What differs is what the headers can say about it.
//
// sortMemberPath names the property this axis sorts on and is the preferred form. The path is
// evaluated by the same binding-based evaluator a column uses for its SortMemberPath, so a
// path that displays also sorts (dotted paths and indexers included), and a bound TableView
// can match the path to a column and light that column's sort indicator. Re-sorting the same
// path replaces that axis in place rather than adding a second one.
// Throws E_INVALIDARG when sortMemberPath is empty.
// Overload metadata is frozen at v1 — without it MIDL bakes Sort2 into the ABI.
[method_name("SortByPath")]
TableViewSource Sort(String sortMemberPath, SortDirection direction);
// keySelector form: for keys no property path expresses — computed, multi-field, normalized,
// or the identity of a primitive collection. The axis is ANONYMOUS: nothing names a property,
// so a bound TableView cannot attribute it to a column and clears every sort indicator
// instead of leaving one describing a sort that is no longer primary. Prefer the
// sortMemberPath overload whenever the key is a property.
//
// The axis is keyed by delegate instance: the same instance re-sorts it in place and None removes
// it; any other instance (e.g. a re-evaluated lambda) adds a new least-significant axis, and
// None with an instance that keys no axis leaves the sort unchanged. To remove the axis, pass
// the same delegate instance with None; ClearSort removes every axis.
[default_overload] [method_name("Sort")]
TableViewSource Sort(TableViewKeySelector keySelector, SortDirection direction);
TableViewSource ParentBy(TableViewKeySelector keySelector, TableViewKeySelector parentKeySelector);
TableViewSource ClearFilter();
TableViewSource ClearGroupBy();
TableViewSource ClearSort();
// Declares a self-referencing parent/child relation over the flat source: keySelector returns
// each item's unique key, parentKeySelector its parent's key (null or empty for a root; an
// unknown parent key makes an orphan root). Null selectors, duplicate/empty keys, self-parent
// and cycles throw E_INVALIDARG and keep the previous projection. Value-typed keys compare by
// value and type; other objects (including app-defined enums and structs) by reference, never
// by Equals. Rows start collapsed; sort applies per sibling set, filter keeps matching rows plus
// their ancestors, and GroupBy buckets the roots.
// See docs/design-notes/TabularControls/Hierarchical-Data-Rows-Parent-Key-Design.md.
TableViewSource ParentBy(TableViewKeySelector keySelector, TableViewKeySelector parentKeySelector);
// Removes the relation and shows the source flat again. Resets expansion state.
TableViewSource ClearParentBy();
// Opt-in: reshapes when an item's sort key, group key, filter result or parent relation changes
// (PropertyChanged), coalesced to one reshape per dispatcher turn and deferred while a cell is
// being edited. Each reshape re-reads every item (O(n) in the source size), however few
// changed. A PropertyChanged raised off the UI thread is marshalled to it; one that cannot be
// is picked up by the next live change on the UI thread.
Boolean IsLiveShaping{ get; set; };
static TableViewSource From(Object items);
}
}
+1 -14
View File
@@ -1,20 +1,7 @@
// Do not move to Microsoft.UI.Xaml.Data: C# consumers then fail with CS0234. See docs/design-notes/TabularControls/TableView-dev-spec.md.
namespace MU_XC_NAMESPACE
{
// Control-agnostic sort direction for the shaping primitives. Declared in layer 1 rather than
// beside any one consumer, because TableViewSource, TableViewColumn and TableViewSortingEventArgs
// all name it.
//
// The namespace is load-bearing, not cosmetic: this type must NOT be declared into
// Microsoft.UI.Xaml.Data, a namespace the Windows App SDK also owns. A managed consumer
// referencing both winmds binds its C# projection to the SDK's copy of that namespace, which
// makes a type declared there by this binary invisible:
//
// error CS0234: The type or namespace name 'SortDirection' does not exist in the
// namespace 'Microsoft.UI.Xaml.Data'
//
// Declaring shaping types only in namespaces this binary exclusively owns removes the
// split-namespace collision at the root.
[MUX_PREVIEW]
[webhosthidden]
enum SortDirection
@@ -1,31 +1,11 @@
#define MU_XC_NAMESPACE Microsoft.UI.Xaml.Controls.Tabular
// Do not reorder: this file must stay the first @(Midl) entry of the Tabular project. Do not redeclare the
// Microsoft.UI.Xaml.CustomAttributes types here; they come from MUXC's winmd. See docs/design-notes/TabularControls/TableView-dev-spec.md.
#define MU_XC_NAMESPACE Microsoft.UI.Xaml.Controls.Tabular
#define MU_XCP_NAMESPACE Microsoft.UI.Xaml.Controls.Tabular.Primitives
#define MU_XCD_NAMESPACE Microsoft.UI.Xaml.Data
#define MU_X_XTI_NAMESPACE Microsoft.UI.Xaml.XamlTypeInfo
// Deliberately the SAME value MUXC's metadata IDL uses, not a Tabular-specific child such as
// Microsoft.UI.Xaml.Controls.Tabular.Primitives.Private. A child namespace breaks the projection
// outright: cppwinrt emits an include for the parent namespace and no parent is ever generated.
// Reusing the value is safe because MIDL never references MUXC's private winmd from this build, so
// there is no duplicate-runtimeclass hazard, and framework-internal primitives stay out of every
// shipped winmd either way because metadata is partitioned by namespace.
// Do not change: must equal MUXC's value; a Tabular-specific child namespace breaks the cppwinrt projection.
#define MU_PRIVATE_CONTROLS_NAMESPACE Microsoft.UI.Private.Controls
// ===========================================================================
// Tabular controls DLL (Microsoft.UI.Xaml.Controls.Tabular) metadata IDL.
//
// This is a renamed copy of controls\idl\Microsoft.UI.Xaml.Controls.idl. MUXC's
// winmd already owns XamlControlsXamlMetaDataProvider and XamlControlsResources,
// so the Tabular DLL PRODUCEs differently-named types to avoid duplicate-runtimeclass
// collisions: XamlControlsTabularXamlMetaDataProvider and TabularControlsResources.
//
// The Microsoft.UI.Xaml.CustomAttributes types (MUXHasCustomActivationFactoryAttribute
// etc.) are NOT redeclared here — MIDL references MUXC's winmd (see
// controls\Tabular.ProjectImports.targets), which already defines them. Only the
// MUX_* macros are kept verbatim, since tabular control IDLs depend on them. This
// file is the FIRST entry in the Tabular project's @(Midl) so those macros are
// visible to all control IDLs merged after it.
// ===========================================================================
#define MU_X_XTI_NAMESPACE Microsoft.UI.Xaml.XamlTypeInfo
namespace features
{
@@ -1,550 +1,340 @@
<!-- The purpose of this document is to describe the design and implementation of a new WinUI control.
This document contains architectural and implementation details that do not appear in the functional spec. -->
# Description
`TableView` is the minimal, display-only tabular base control for `Microsoft.UI.Xaml.Controls.Tabular.dll`. It renders an `ItemsSource` as virtualized rows and uses `Columns` to generate one cell per realized row/column intersection.
`TableView` is a virtualized tabular control that ships in `Microsoft.UI.Xaml.Controls.Tabular.dll`. It renders an `ItemsSource` as rows and generates one cell per realized row and column from `Columns`. It is read-only by default with opt-in cell editing.
This is the minimal, display-only base of the control, and it is read-only. It establishes column ownership, header/body layout, row virtualization, cell generation, leading frozen columns, basic row keyboard navigation, automation peers, density/theming hooks, and the lifetime patterns later work builds on. The interactive v1 features land additively on top of it: selection, filtering, single-column sort, single-level grouping and hierarchical rows are delivered; column resize/reorder is still outstanding.
Feature-level requirements and the v1-vs-deferred breakdown are in [`TableView-functional-spec.md`](./TableView-functional-spec.md).
Theme-XBF emission is intentionally suppressed in this PR. The control compiles into `Microsoft.UI.Xaml.Controls.Tabular.dll`, but the binary does not emit its own theme XBF yet.
It provides column width sizing, row virtualization, leading frozen columns, single-row selection, single-column, control-driven sorting, filtering, single-level grouping, hierarchical rows, column resize, cell-level keyboard navigation and a UIA grid/table model. Column reorder is not yet implemented.
# Design considerations
`TableView` is a flat table renderer, not a full data-grid. It focuses on predictable display of many rows with a small-to-medium column count.
`TableView` is a flat table renderer, not a full data grid. It targets many rows and a small-to-medium column count.
The API preserves migration-compatible concepts where useful:
**WPF migration.** The API keeps WPF `DataGrid` concepts where they help:
- `TableViewTextColumn.Binding` is a plain CLR property, matching WPF `DataGridBoundColumn`.
- `TableViewHeadersVisibility` is a flags enum with `None` and `Column` only; row headers are out of scope, so `Row`/`All` (WPF values 2/3) are intentionally not defined.
- `TableViewGridLinesVisibility` preserves WPF `DataGridGridLinesVisibility` values.
- `TableViewTextColumn.Binding` is a plain CLR property, like `DataGridBoundColumn.Binding`.
- `TableViewHeadersVisibility` has only `None` and `Column`; row headers are out of scope.
- `TableViewGridLinesVisibility` keeps WPF's names and values, so persisted casts round-trip.
- `Width` is a `GridLength`, so XAML can express `Pixel`, `Auto` and `*`.
`Width` is typed as `GridLength` so XAML can express `Pixel`/`Auto`/`*` intent. All three resolve in v1: `Pixel` is exact, `Auto` sizes to the widest realized cell (it grows within a data set and never shrinks — true auto-shrink is deferred), and `*` takes a proportional share of the body viewport left after the fixed columns. Resolution is owned by the table (see the *Column width sizing* section below); `MinWidth`/`MaxWidth` clamp every mode.
**Frozen columns** are a contiguous leading prefix. `TableViewFrozenEdge.Trailing` is reserved.
Frozen columns are limited to a contiguous leading prefix in v1. `TableViewFrozenEdge.Trailing` is reserved.
**Groups are a separate container kind.** A group header is a `TableViewGroupHeader` interleaved into the flat row list, not a nesting level, so `TableViewRow` never needs an "am I a header?" branch. Hierarchical rows also stay a flat list: each row carries its level and expansion state, and its lead cell is indented.
Single-level grouping is delivered (see *Grouping model* below): group headers are their own container kind interleaved into the flat row projection, not a second nesting level in the layout. Hierarchical rows are delivered through `TableViewSource.ParentBy(keySelector, parentKeySelector)` over a flat source, with no depth limit: each visible row carries its level and expansion state, and the row's lead cell is indented with an expander gutter, so the layout stays a flat virtualized list. See [Hierarchical data rows design](Hierarchical-Data-Rows-Parent-Key-Design.md).
**`TableViewRow` is public and unsealed** although its properties are read-only. It is the realized row container (like `ListViewItem`), and the public row and cell automation peer constructors take it.
`TableViewRow` is intentionally a public `unsealed` `Control` despite carrying no v1 dependency properties: it is the realized row container (mirroring `ListViewItem`/`DataGridRow`) and is referenced by the public `TableViewRowAutomationPeer` and `TableViewCellAutomationPeer` constructors, so it cannot be internalized without collapsing the public accessibility surface. Row-level features layer onto it: selection and hierarchy already do. Grouping deliberately does **not** — a group header is a `TableViewGroupHeader`, a separate container kind chosen by `TableViewRowTemplateSelector`, so `TableViewRow` never grows an "am I a header?" adaptive branch.
**Two built-in column types.** `TableViewTextColumn` is the efficient common case (one `TextBlock` per cell); `TableViewTemplateColumn` hosts any visual through `CellTemplate`. Without a `CellTemplate` it renders an empty cell, not `ToString()`, as in WPF, so type names never leak into cells. Zero-configuration display belongs to a future `AutoGenerateColumns`.
# Functional spec link
## Column model: `TextColumn` vs `TemplateColumn`
`TableView` ships two built-in column types rather than one, mirroring WPF
(`DataGridTextColumn` / `DataGridBoundColumn` vs `DataGridTemplateColumn`):
- `TableViewTextColumn` is the built-in **property-bound text** path: it generates a bare
`TextBlock`, applies its CLR `Binding` to `Text`, and carries the default table cell styling
(font, vertical centering, density-aware padding, character-ellipsis trimming). It is the
efficient common case (one `TextBlock` per cell, no template inflation) and the natural home
for the future editing story (a text cell's editing element layers onto it).
- `TableViewTemplateColumn` hosts **arbitrary consumer visuals** via `CellTemplate`, bound
against the row item through `ContentPresenter.Content = dataItem`.
A `TableViewTemplateColumn` with **no `CellTemplate` renders an empty cell** - it deliberately
does **not** fall back to `dataItem.ToString()`. This matches WPF's `DataGridTemplateColumn` and
avoids leaking type-name strings (e.g. `MyApp.Person`) into cells when a template is forgotten.
Zero-configuration "just show my data" is intentionally **not** solved by `ToString()`; the
principled answer is column auto-generation from the item's public properties
(`AutoGenerateColumns`, a candidate follow-up feature), which produces real bound
`TableViewTextColumn`s rather than stringified objects. Consumers who explicitly want the whole
item's string form can use `TableViewTextColumn Binding="{Binding}"`.
# Layout
The default `TableView` template is a two-band layout: a chrome `Border` (rounded with `ControlCornerRadius` for a card edge) containing a `Grid` with two rows — row 0 `Auto` (header band) and row 1 `*` (body band). The corner rounding is render-only and does not shrink the body viewport, so row virtualization is unaffected.
```text
TableView — Default Template
└─ Border (chrome)
└─ Grid ─ Row 0 = Auto (header) · Row 1 = * (body)
├─ Header band [Row 0, Auto]
│ └─ Border PART_HeaderRow (header background + 1px bottom gridline)
│ └─ ScrollViewer PART_HeaderScroller (H-only; scrollbar hidden; V disabled; synced to body)
│ └─ TableViewCellsPanel PART_HeaderHost -> one TableViewColumnHeader per column (L->R)
└─ Body band [Row 1, *]
└─ ScrollViewer PART_BodyScroller (H + V, auto scrollbars)
└─ Grid PART_BodyContent
├─ ItemsRepeater PART_RowsRepeater (vertical StackLayout; ItemTemplate = TableViewRow)
└─ ContentControl PART_EmptyStatePresenter (overlay; Collapsed unless data empty)
TableViewRow — ItemTemplate (realized / recycled by PART_RowsRepeater)
└─ Border PART_RootBorder (MinHeight 40; 1px bottom gridline; CommonStates: Normal/PointerOver/Pressed/Disabled)
└─ TableViewCellsPanel PART_CellsHost -> one cell-wrapper Border per column (hosts GenerateElement, L->R)
```
Each `TableViewCellsPanel` measures its cells unconstrained and arranges them at the owning column's `ActualWidth` (no `Width` binding) — see *Column alignment* below.
*Structural view of the visual tree. Leading frozen-column counter-translation/clip and the intentional omission of column virtualization are runtime behaviors, covered in the Frozen leading columns and Row virtualization sections below.*
## Header band
`Border` `PART_HeaderRow` → `ScrollViewer` `PART_HeaderScroller` → `TableViewCellsPanel` `PART_HeaderHost`.
`PART_HeaderRow` provides the header-surface background and a 1px bottom rule. `PART_HeaderScroller` is horizontal-only (`HorizontalScrollMode="Auto"`, horizontal scrollbar hidden, vertical disabled). `PART_HeaderHost` is a `TableViewCellsPanel` (inherently horizontal); `RebuildHeaders` fills it with one `TableViewColumnHeader` per non-null column (each cell mirrors the column's `Visibility`), in `Columns` order, left-to-right. Header cells use the density row minimum height, so the header band matches body rows. Like rows, header cells carry `ControlCornerRadius`, consumed only by the system focus visual, so header keyboard focus is rounded. Header content comes from `TableViewColumn.Header`, displayed via `HeaderTemplateSelector` when set, otherwise `HeaderTemplate`, otherwise the default text presenter. Header lines are always drawn, independently of the body's `GridLinesVisibility`: each header's full-height 1px `PART_GridLine` separator on its trailing edge and the 1px rule under `PART_HeaderRow`, both with `TabularSurfaceHeaderSeparatorBrush`; overriding that resource with `Transparent` hides them. `TableView::ApplyGridLinesToHeader` owns the separators; the template draws the bottom rule (`PART_HeaderRow`'s `BorderThickness="0,0,0,1"`). Like the body's vertical lines, the last visible column's separator is suppressed only where it would double the outer border. A focused header keeps focus when `RebuildHeaders` replaces the band (a header, template or theme change); hiding the focused column moves focus to the next visible header, or the previous one when it was last.
**Pointer feedback.** A sortable header (`CanUserSortColumns` and `TableViewColumn.CanSort`) reacts to the pointer like a row: on hover it paints `TabularSurfaceHeaderBackgroundPointerOverBrush`, while pressed `TabularSurfaceHeaderBackgroundPressedBrush`, and it returns to its transparent fill on exit, release, capture loss or cancel (a capture loss or cancel from the resize gripper's own drag capture is ignored, so a header still under the pointer stays hovered). In High Contrast only, the default header text presenter and sort indicator also take `TabularSurfaceHeaderForegroundPointerOverBrush` / `TabularSurfaceHeaderForegroundPressedBrush` (`SystemColorHighlightTextColor` on a `SystemColorHighlightColor` fill). In Light and Dark the header text keeps its inherited foreground, so an app-set `TableView.Foreground`, `HeaderTemplate` foreground or `SortIndicatorForeground` is not overridden on hover; there those foreground keys equal the default rest foreground (`TabularSurfaceRowForegroundBrush`), and the fills equal the row pointer-over/pressed fills. The fills and High Contrast foregrounds are `{ThemeResource}` setters in the `TableViewColumnHeader` template's `CommonStates` and `HighContrastStates`; a theme or High Contrast change rebuilds the header band, so each header restarts in `Normal`/`HighContrastNormal`. `Disabled` dims the text and sort glyph with `TabularSurfaceRowForegroundDisabledBrush`. A header that cannot sort shows no feedback, the pointer events are never marked handled (click-to-sort and focus are unchanged), and a press on the resize gripper stays with the gripper.
## Body band
`ScrollViewer` `PART_BodyScroller` → `Grid` `PART_BodyContent` → `ItemsRepeater` `PART_RowsRepeater`.
`PART_BodyScroller` enables both horizontal and vertical scrolling with auto scrollbars. `PART_RowsRepeater.Layout` is a vertical `StackLayout` (`HorizontalCacheLength`/`VerticalCacheLength` = `2.0`; `ItemTemplate` = `TableViewRow`). `PART_EmptyStatePresenter` is a `ContentControl` overlaid in the same `PART_BodyContent` cell, `Collapsed` unless the empty state is shown.
## Row
`Border` `PART_RootBorder` → `TableViewCellsPanel` `PART_CellsHost`.
`PART_RootBorder` is the row chrome: it supplies the default 40px minimum row height, a 1px bottom gridline, and the `CommonStates` visual states (`Normal`, `PointerOver`, `Pressed`, `Disabled`) that animate the row background. `PART_CellsHost` is a `TableViewCellsPanel` (inherently horizontal); `RebuildCells` fills it with one cell-wrapper `Border` per non-null column (mirroring the column's `Visibility`), in `Columns` order, left-to-right. Each wrapper hosts the element returned by `TableViewColumn.GenerateElement(dataItem)` when non-null. The row's `CornerRadius` (default `ControlCornerRadius`) is consumed only by the system focus visual (`UseSystemFocusVisuals="True"`, which rounds to the focused element's corners), so row/keyboard focus is rounded; it is intentionally **not** bound to `PART_RootBorder`, keeping the row background and 1px bottom gridline rectangular across the gapless row stack (no corner notches between adjacent rows). Group headers follow the same rule. Cells and header cells are `Grid`s, and a rounded `Grid` clips its children and its hit-test area, so they take the TableView's `CornerRadius` (`ControlCornerRadius` by default, set in XAML) only while the focus rect shows (keyboard focus, or programmatic focus after keyboard input) (`TableView::AttachFocusCornerRadius`). The focus rect is rounded, and the clip stays under it.
## Column alignment
The header band and each realized row host their cells in a `TableViewCellsPanel` — a custom `Panel` whose `MeasureOverride` measures every cell twice (first unconstrained, to learn its measured width for Auto sizing; then a second pass constrained to the column's resolved `ActualWidth`) and whose `ArrangeOverride` places each cell at that `ActualWidth` (`x` = sum of preceding visible columns' widths; a collapsed column arranges to zero width). The second, constrained measure matters: without it, a cell whose content is wider than its column keeps a `DesiredSize` larger than the column, so XAML's arrange would expand the cell's *render* size to that desired width and clip it to the column slot — which clips away the cell's right border (the vertical gridline). Constraining the measure keeps the ellipsized content and the border inside the column so the boundary gridline renders. The header band and every realized row stay aligned with no cross-row coordination because every panel reads the *same* shared `TableViewColumn.ActualWidth`. Cells carry **no** `Width` binding: an explicit `Width` would defeat the panel's unconstrained measured-width measurement, so the panel arranges to the resolved width directly.
**Layout ownership — table-level.** Column-width resolution is a single **table-level** decision, computed once per layout pass. `TableView::MeasureOverride` measures the template subtree — which realizes the viewport rows and runs each `TableViewCellsPanel::MeasureOverride`, caching every cell's measured width per column — then calls `ResolveColumnWidths` exactly once: it pulls the max measured width per column across the header host and the currently realized rows and resolves Pixel/Auto/Star. Cells and rows only *measure and cache*; they never decide or push a width, so a column's state is written once per pass rather than once per realized cell. When a resolved width changes, `TableView` directly invalidates the header + realized row panels (no cross-object event), which re-arrange to the new `ActualWidth`; a stable data set settles in one extra pass. The row's cell host is always the controlled `TableViewCellsPanel`, so a custom row template can restyle cell *content* without breaking column alignment.
## Column width sizing
Column widths are resolved by the table in a single pass owned by `TableView::MeasureOverride`; the engine lives in `TableView_Layout.cpp`. Each layout pass runs a four-step pipeline and writes each column's read-only `ActualWidth`, which each cells panel reads when it arranges:
1. **Measure + cache** — `TableView::MeasureOverride` measures the template subtree; each realized `TableViewCellsPanel` (header + rows) measures its cells unconstrained and caches the measured width per column (`MeasuredWidthForColumn`). Cells do not push to the table.
2. **Resolve (once)** — `ResolveColumnWidths` pulls the max measured width per column across the header host and the realized rows, then turns `Width`/`MinWidth`/`MaxWidth` plus the body viewport into `ActualWidth`: `Pixel` → the given pixels; `Auto` → the pulled max (grow-only, see below); `*` → a proportional share of the viewport left after the fixed columns, with min/max clamping and re-division (the WPF `ComputeStarColumnWidths` shape).
3. **Share** — the resolved `ActualWidth` on each `TableViewColumn` is read by every header/row `TableViewCellsPanel` when it arranges; if a width changed, `TableView` directly invalidates those panels so they re-arrange atomically.
4. **Arrange** — each `TableViewCellsPanel.ArrangeOverride` places its cells left-to-right at those widths (leading frozen cells are then pinned to the horizontal scroll offset).
Only realized rows contribute, so `Auto` sizes to the widest *realized* cell. `Auto` is **grow-only** within a data set: the per-column accumulator only increases and is reset only on data-set boundaries — an `ItemsSource` change, the `Columns` collection being *replaced*, a `CellTemplate` change, or a header change. The row cells keep a grow-only maximum per column, while the header term is its current measurement (headers are not virtualized), and the Auto width is `max(rows, header)` with the header term dropped while `HeadersVisibility` is `None`. Toggling the headers therefore adds or drops only the header term and never resets the rows' max, and a shorter header or turning off sorting (which removes the sort indicator's reserve) narrows the column. An in-place collection `Reset` or clear-and-refill, a `TableViewSource` filter/refilter, and a `Density` change are *not* data-set boundaries; an app that wants columns re-fit re-assigns `ItemsSource` (or sets a `Pixel` `Width`). An *incremental* `Columns` add/remove does **not** reset the pre-existing columns' accumulators (their row data is unchanged, so resetting them would let an unrelated column shrink to only the currently realized rows); the newly inserted column simply starts fresh and grows from content. `Min`/`MaxWidth` changes re-resolve without resetting. `*` needs a finite width to divide: the horizontally-scrolling body panel is measured at infinite width, so the engine pulls `ScrollViewer.ViewportWidth` explicitly and re-resolves on the body scroller's `SizeChanged`. `Auto` auto-shrink and column virtualization are out of scope for v1.
**Known layout limitations (v1).**
- **`*` requires a bounded viewport.** Star columns divide the body `ScrollViewer.ViewportWidth`. In a width-to-content host (the table given an infinite width constraint, e.g. inside an auto-sized `StackPanel`), there is no finite width to divide, so `*` columns stay at their provisional default width. Give the table a bounded width — or use `Pixel`/`Auto` columns — in width-to-content layouts.
- **Fixed columns wider than the viewport.** When the `Pixel` + `Auto` (fixed) columns already exceed the viewport, the space left for `*` is zero, so `*` columns collapse to their `MinWidth` and the body scrolls horizontally. This matches WPF `DataGrid`'s star-vs-fixed behavior.
- **Width-dependent cell height.** Cells are measured unconstrained (infinite width) to learn their measured width, then arranged at the resolved column width. A `CellTemplate` whose *height* depends on its *width* (a wrapping `TextBlock`, an aspect-fit image) is measured for its single-line/unwrapped height and is clipped when arranged narrower. The built-in `TableViewTextColumn` uses single-line `CharacterEllipsis`, so it is unaffected; authors of wrapping `TableViewTemplateColumn` content should set an explicit row height or avoid width-dependent wrapping in v1.
## Column resize (`ResizeGripper`)
Interactive column resize is a **policy split** between a framework-internal primitive and the table. `ResizeGripper` (`Microsoft.UI.Private.Controls`, shipped only in `Microsoft.UI.Xaml.Controls.Tabular.dll`) is a *delta source*: it owns the gesture and reports how far the user has dragged. It owns no value, no bounds and no layout, because `TableViewColumn` already owns `Width`/`MinWidth`/`MaxWidth`/`ActualWidth` — a second copy of that state is what the primitive deliberately avoids.
**The primitive owns** the hit target, its own presentation (visual states plus the `ResizeGripperSeparatorBrush`/`ResizeGripperSeparatorThickness` theme keys), the resize cursor, the input protocol, and the gesture state machine: `IsDragging`, a 0.5 DIP deadband, rejection of non-finite deltas, re-entrancy guards, and cancel semantics.
**The table owns** every policy decision: whether a column gets a gripper at all (`CanUserResizeColumns` + `TableViewColumn.CanResize`), the gripper's placement and width (`TableViewResizeGripperWidth`), the anchor captured at `DragStarted`, clamping the reported delta to `MinWidth`/`MaxWidth`, writing `Width`, reverting on cancel, and the UIA width announcement.
**Protocol.** `BeginDrag()` → zero or more `DragDelta` → `EndDrag(canceled)`. `DragDelta` carries `Delta` (since the last raise) and `TotalDelta` (from the start of the gesture), so a host applies `TotalDelta` against the anchor it captured rather than accumulating. `DragCompleted.Canceled` distinguishes a torn-down gesture (Escape, a canceled contact, lost mouse capture, the gripper disabled, the header rebuilt mid-drag) from a release: on cancel the host restores the **authored `GridLength`**, so a canceled drag cannot silently rewrite an `Auto` or `*` column as fixed pixels. `BeginDrag`/`TryDrag`/`EndDrag` are public so a host can drive the identical gesture from its own input handling — that is how keyboard resize stays indistinguishable from a pointer drag.
**Input.** Pointer input arrives as manipulation events, so touch and pen contacts and multi-pointer arbitration come from the framework's gesture recognizer rather than from each host. The gripper captures the mouse only, on press, so the resize cursor holds while the pointer leaves the gripper mid-drag; touch and pen stay uncaptured so an ancestor `ScrollViewer` can still pan the cross axis. `ManipulationMode` is a single translate axis and is set in the constructor, not the default style: a host whose style failed to load must not silently lose pointer resize. Because the gripper travels with the edge it drags, `ManipulationStarting` reparents the measurement frame — the default container is the element itself, whose motion would feed back into the gesture and stall it.
**Axis and RTL.** `DragOrientation` is the axis the drag is **measured along**, which for a divider is perpendicular to how it looks: `Horizontal` drags left/right. It is named `DragOrientation` rather than `Orientation` precisely because the primitive owns no shape — the host sizes it — so the control's own layout axis is not something it can describe. This is also the opposite of the Windows Community Toolkit's `SizerBase.Orientation`, which describes the bar; the distinct name prevents a silent mix-up. The primitive normalizes to a **logical** delta, so positive always grows in reading order and both input paths agree; only the horizontal axis mirrors under RTL.
**Keyboard.** The header cell, not the gripper, is the keyboard target. Column commands belong there, and one tab stop per column would sit between the user and the data. The table therefore sets `IsTabStop(false)` on the gripper and routes **`Alt+Left`/`Alt+Right`** on a focused header into `TryKeyboardStep(VirtualKey)`. That function owns direction, the RTL mirror, `KeyboardIncrement` and the Shift multiplier (`Alt+Shift+Arrow` is the large step), so the pointer and keyboard paths cannot drift apart. `Alt` is the **only** resize modifier: `Ctrl+Arrow` on a header does nothing, and bare `Left`/`Right` move focus between headers (see *Keyboard* below). The binding matches WPF `DataGrid`.
### Resize policy — what each column configuration does
The reference behavior is **WPF `DataGrid`**, and the rules below are derived from measuring it rather than from its source. `ResizeBoundsForColumn` turns them into a `[Min, Max]` the drag is clamped to.
Three rules decide everything:
1. **Donor.** A column can give width away only if it is currently `*`, is user-resizable (`CanUserResizeColumns` and `TableViewColumn.CanResize`), and sits **after** the dragged column. Only `*` qualifies because only `*` columns are re-divided by `ResolveColumnWidths`; reserving space from a `Pixel` or `Auto` neighbour would promise width that never materializes.
2. **Direction.** A resize takes space only from the columns after the dragged divider. `*` columns before it are frozen at the width they already render (`FreezeColumnsBeforeResize`), which keeps them out of both the bound and the redistribution pass without changing what the user sees.
3. **Ownership.** A `*` column can only change width by taking from another `*` column, so with no donor it cannot move at all. A `Pixel` or `Auto` column owns its width outright, so it may still **shrink** even with no donor — that only makes the table narrower and needs nothing from a neighbour.
This yields three outcomes:
| Condition | Outcome |
|---|---|
| No column authored `*` anywhere | **Unbounded** — the column grows, the extent exceeds the viewport, the body scrolls |
| `*` present, at least one donor after the dragged column | **Capped** at `viewport − Σ reserved` |
| `*` present, no donor, dragged column is `*` | **Pinned** — the divider does not move |
| `*` present, no donor, dragged column is `Pixel`/`Auto` | **Shrink-only** — may narrow to its own `MinWidth`, may not grow |
A pinned column shows no gripper. A column whose `MinWidth` is at least its `MaxWidth` can never change width, so it is pinned too, and a fixed-width `*` column is not a donor. Availability is re-evaluated when `Width`, `MinWidth`, `MaxWidth`, `Visibility` or `CanResize` changes. Size changes (`Width`, `MinWidth`, `MaxWidth`) are not re-evaluated mid-drag — the drag itself converts columns to `Pixel` — and are refreshed once on `DragCompleted`; a `Visibility` change re-evaluates immediately, even during a drag. `IsColumnResizePinned` is the one rule shared by the drag bounds and the gripper's visibility; the collapsed gripper stays in the header cell, so `Alt+Arrow` still reaches it and is clamped the same way.
What each other column reserves: a donor reserves its `MinWidth`; a `Pixel` column its pixel width; an `Auto` column its content width; a locked `*` column its share of the **authored** layout, so a neighbour's drag cannot move it.
Two-column expectations, viewport 478, `MinWidth` 50 (`!` = `CanResize="False"`):
| Config | Outcome | Config | Outcome |
|---|---|---|---|
| `P,P` `P,P!` | unbounded, scrolls | `S,S` | capped |
| `P,A` `P,A!` | unbounded, scrolls | `S,S!` | pinned |
| `A,P` `A,P!` | unbounded, scrolls | `S,P` `S,P!` | pinned |
| `A,A` `A,A!` | unbounded, scrolls | `S,A` `S,A!` | pinned |
| `P,S` `A,S` | capped | `A,S!` | shrink-only |
| `P,S!` | shrink-only | | |
**Known divergences from WPF.** WPF's resize *assigns* the neighbour an explicit width instead of relying on a redistribution pass, so it can shrink a `Pixel` or `Auto` neighbour. Ours cannot, so `S,P`, `S,A` and the growth direction of `P,S!,A` **pin** where WPF resizes. This is deliberate: promising space that a non-`*` neighbour never surrenders is what produced the original overflow bug. Matching WPF here would mean adopting its assign-the-neighbour model, which is a larger change to the drag path. All other configurations agree with WPF on whether the divider moves.
## Row virtualization
Rows are virtualized by `ItemsRepeater` on the vertical axis. With the vertical `StackLayout`, only rows whose realization rect intersects the body viewport — plus two viewports of cache on each side — are materialized as `TableViewRow` instances; off-screen rows return to the recycle pool. `StackLayout` is used instead of a uniform two-axis layout because row heights vary with density and template content.
On recycle, `ItemsRepeater` re-points a pooled row's `DataContext` to the new item and cell **data** updates **reactively** through `DataContext` inheritance + bindings — cells are **not** re-stamped imperatively (mutating a live cell during the repeater's measure pass re-enters framework layout and fails fast). `TableViewTextColumn` binds `TextBlock.Text` to the consumer `Binding` (resolved against the inherited `DataContext`); `TableViewTemplateColumn` binds its `ContentPresenter.Content` to the cell-wrapper `Border`'s inherited `DataContext` — bound to the *wrapper*, not the presenter's own `DataContext`, because `ContentPresenter` pins its `DataContext` to its `Content` (a self-referential binding would freeze after the first item). Consequently `RebuildCells` is needed only for structural changes (columns / template / density); a pure recycle refreshes only index-dependent visuals — alternating-row banding (`RefreshRowBackground`) and frozen-column pinning. Custom columns (overridable `GenerateElementCore`) must bind reactively to the inherited `DataContext` for the same reason. This holds on every recycle to a new item: unless a client has realized a cell automation peer, the row reuses its cells, so a custom column that bakes `dataItem` into its element, or template content initialized once per element (in `Loaded`, or through `x:Bind` with `OneTime`), keeps showing the previous item after a scroll.
Column virtualization is intentionally not implemented: each realized row renders a cell for every non-null column (collapsed columns get a hidden cell), which is the right trade for typical 5–50 column tables. Because every realized row materializes one cell wrapper per non-null column, `TableView` targets typical application tables (~5–50 columns) and is **not** designed for spreadsheet-scale column counts (100+). Column virtualization is independent of the width engine: `Auto`/`*` sizing ships in this PR (see *Column width sizing*) without column virtualization, which stays out of scope — each realized row still renders one cell per non-null column.
## Sticky headers
The C++ control drives `PART_HeaderScroller`'s horizontal offset from `PART_BodyScroller.ViewChanged`, so the header band tracks horizontal body scrolling. The sync is one-way (only the body's `ViewChanged` is handled; the header is never the source) and skips near-equal offsets (<0.5px) to avoid `ViewChanged` ping-pong.
## Frozen leading columns
Leading-frozen header and body cells are counter-translated against the horizontal scroll offset. Only the contiguous leading prefix from column 0 is pinned; a later `Leading` column is treated as non-frozen. This requires `ElementCompositionPreview.SetIsTranslationEnabled` (a `UIElement.Translation` X/Y change is otherwise a no-op). Non-frozen cells are clipped out of the pinned band so horizontally scrolled content does not draw underneath the frozen leading cells. This translation/clip approach is a v1 rendering detail; later interactive features (resize, reorder, selection) must not assume it.
# Implementation Details
The render pipeline is `TableView.ItemsSource` → `PART_RowsRepeater` → `TableViewRow` → per-column cell wrapper → `TableViewColumn.GenerateElement(dataItem)`.
`TableView` owns `PART_RowsRepeater.ItemsSource` (the template does not bind it directly). `Unloaded` clears the repeater source so queued repeater cache-build work cannot run against a detached subtree.
`ThemeSettings` (high-contrast tracking) is released on `Unloaded`, or earlier on the host `AppWindow.Destroying`, because removing its `Changed` handler after the window is destroyed fails fast. Hosts without an `AppWindow`, or whose top-level window belongs to another thread, use the `Unloaded` path.
Cell generation is column-specific:
- `TableViewTextColumn` creates a `TextBlock` and applies `Binding`.
- `TableViewTemplateColumn` instantiates `CellTemplate`; a null template produces an empty cell.
- Custom columns override `GenerateElementCore(Object dataItem)`.
`RowBackground` and `AlternatingRowBackground` provide opt-in row banding; when both are null, rows stay unbanded. `GridLinesVisibility` controls row/cell gridline borders; the column-header separators and bottom rule are always drawn. `Density` selects row minimum height and built-in cell/header padding for `Compact`, `Standard`, or `Comfortable`.
Keyboard handling is cell-aware and follows the WAI-ARIA treegrid model. `TableView` listens to bubbling `KeyDown` so template-column descendants handle input first. The table itself is not a tab stop (`IsTabStop=False`). It has **two tab stops**: the header band, then the body. The two share one column cursor, so Tab and Shift+Tab between them keep the current column.
- **Header band.**
- The band is entered at the remembered column, or at the first header when no column has been chosen.
- `Left`/`Right` move between visible headers and do not wrap (mirrored under RTL).
- `Down` moves to the first row; `Up` stays in the band.
- `Enter` and `Space` sort a sortable header (`Space` fires on key-up, so auto-repeat cannot re-sort). `Alt+Left`/`Alt+Right` resize it.
- Every visible header is reachable, including one with `CanSort=False`.
- `Tab` into a scrolled body from the column header band lands on the first fully visible enabled row or group header in the body viewport (a partially visible one if none is fully visible), or the last one for `Shift+Tab`; for a row at cell level, on the cell in the current column. Landing on a group header from the band, scrolled or not, leaves cell level, but the column cursor is kept. If no enabled element is in the viewport, the framework's target is kept. It never targets a cache row, so the body scrolls no further than the focus bring-into-view padding.
- **Rows.**
- Unhandled `Up`, `Down`, `PageUp` and `PageDown` keep the starting level: row to row, or cell to cell in the same visible column. `Up` from the first row moves to the header band.
- `PageDown` pages like `ListView`: it first moves to the last visible row, and from there one viewport (less one row) further, aligning that row to the bottom. `PageUp` mirrors it towards the first visible row and the top. The visible row is the last (or first) fully visible enabled data row or group header, falling back to a partially visible one when none is fully visible; disabled rows are skipped. A group header at that edge is a page target like a row (focused at row level, since headers have no cell level), so a viewport holding a single data row between headers still pages by the viewport rather than one header at a time. While focus is on a row or a cell the table handles both keys itself, so the body scroller does not page on its own; when focus is inside hosted cell content (or an open editor) the keys are left to that content and the body scroller's native paging.
- A row that keyboard navigation focuses is scrolled fully into view, including a row that is not realized yet, such as the last row on `End`. Focusing a row, group header or cell whose row is already fully in view does not scroll the body: XAML pads a keyboard-focus bring-into-view by 20 DIPs, and the table drops that padding vertically (`TableView::OnBodyBringIntoViewRequested`), so for example the first `PageDown` does not nudge the viewport.
- From a focused row, `Right` drills into the first cell. `Left` on the first cell returns to the row. `Left` on a row does nothing. On a row, `Left`/`Right` are handled before the body scroller sees them, so they never scroll the body on their own; only a newly focused cell is brought into view.
- At cell level, `Left`/`Right` move the cell cursor within the focused row without wrapping. `Home`/`End` move to the first/last cell of the current row, and `Ctrl+Home`/`Ctrl+End` move to the first/last cell of the table. All horizontal keys mirror under RTL.
- **Selection.**
- Selection follows keyboard focus.
- `Ctrl` plus a navigation key moves focus without selecting.
- `Space` (without `Alt` or `Ctrl`) selects the focused row from row or cell level.
- **Cell content.** `Enter` on a cell that hosts a focusable control moves focus into that control, and arrow keys then stay inside it. `Escape` returns to the cell. `F2` remains the begin-edit key.
- **Re-entry.** Focus entering the body from outside the table with direction `Next` or `Previous` (`Tab`/`Shift+Tab`, including a programmatic `TryMoveFocus(Next/Previous)`) returns to the same **record** that last had focus, even after a sort or filter has moved it, at the level it was left at: the same visible column at cell level, or the row at row level. This holds whichever row element the framework aimed at, including a control hosted in another row's template cell. The remembered record is resumed if it is realized and its row is enabled; otherwise entry lands on the first (`Tab`) or last (`Shift+Tab`) enabled row inside the body viewport at row level, so the fallback never targets a cache row or a disabled row. The same fallback applies when the remembered target can't take focus; the redirect is skipped only when no viewport row can. Entry aimed at a group header is not redirected. The entry is redirected at most once per focus operation. Pointer, arrow-key, UIA and `Focus()` moves are never redirected.
- **Re-shape while focused.** When a sort or filter re-shapes the rows while focus is **inside** the body, focus stays at the same **position**, the same projected row index, and so lands on whichever row now occupies it. This also applies when the focused record was filtered out. The body position is put back only when focus is in the body when the re-shape runs, or when the re-shape itself drops focus. If focus is anywhere else, focus stays there. This includes a column header (however focus got there, by keyboard, pointer, app code, or a UIA `SetFocus`) whose Enter, Space, click, or UIA Invoke caused the sort, and controls outside the TableView.
- **Gamepad.** Gamepad, D-pad and XY-focus navigation are not supported. Only the keyboard routing above is specified.
The internal `GridCoordinateHelper` performs the row/column ↔ flat-index math, wrap behavior, and overflow guards.
Accessibility exposes a UIA grid/table model:
- `TableViewAutomationPeer`: `IGridProvider`, `ITableProvider`, `ISelectionProvider`, `IItemContainerProvider`. `BoundingRectangle` is clipped to the control's layout box, so realized cache rows outside the body viewport are excluded; the default bounds are used when the clip is unavailable.
- `TableViewRowAutomationPeer`: `DataItem` control type, `ISelectionItemProvider`, composed name and group-relative `PositionInSet`/`SizeOfSet`; no grid/table provider (exposes its cell peers as children)
- `TableViewCellAutomationPeer`: `IGridItemProvider`, `ITableItemProvider`, `IValueProvider`, `Custom` control type with localized `cell`. The name is the cell value; while column headers are shown, the column header is reported only through `GetColumnHeaderItems`, and an empty cell is named with the localized `TableViewCellBlankName` ("blank"). While they are hidden, `GetColumnHeaderItems` (and the table's `GetColumnHeaders`) is empty and the name becomes "{header}, {value}"; an object or templated header contributes its header item's name. A cell whose content is focusable (a `Button`, `ToggleSwitch`, ...) is named after its column header alone in both cases, because the control is a Content-view child that names itself. Toggling the column-header bit of `HeadersVisibility` raises `Name` changed on cell peers that already exist.
- `TableViewColumnHeaderAutomationPeer`: column-header name/bounds, `IInvokeProvider` (sort), `PositionInSet`/`SizeOfSet`
- `TableViewGroupHeaderAutomationPeer`: `IExpandCollapseProvider`, `IGridItemProvider`. It computes **no** `Level`: grouping is single-level, so the band reports `0` ("not specified") unless the app sets `AutomationProperties.Level`.
- A stale `GridItem` (a cell whose row or item is gone, a cell replaced by a same-row rebuild such as a column reorder or `CellTemplate` change, a cell whose column is collapsed, or a recycled group header) throws `UIA_E_ELEMENTNOTAVAILABLE` from `Row` (a cell also from `Column`) instead of reporting `-1` or another cell's coordinate. A cell whose row is between recycle and re-prepare while its item is still in the source reports the item's index.
`ScrollItem` is not implemented explicitly on any of these — `FrameworkElementAutomationPeer` already supplies a `ScrollItemAdapter` for every peer.
Peers that compute `PositionInSet`, `SizeOfSet` or `Level` report `0` — UIA's "not specified" — when the value cannot be resolved, never `-1`, and an app-set `AutomationProperties` value always wins over the computed one.
Tabular's compiled `ResourceAccessor` reads `Microsoft.UI.Xaml.Controls.Tabular/Resources`, matching its PRI subtree. The in-box MUXC build retains `Microsoft.UI.Xaml/Resources`. A missing resource still falls back safely, but a correctly merged Tabular PRI now supplies the localized cell, sort, group and resize strings.
Lifetime rules: columns and rows use weak owner back-pointers (`GetOwningTableView()` resolves a strong owner for synchronous work); runtime classes use `ReferenceTracker` where required; cross-object events use `auto_revoke`; recycled rows reset transient visual state before reuse.
Theme values resolve through `TabularSurfaces` resources and re-resolve across theme (and high-contrast) changes. `TabularSurfaceGridLineBrush` (body grid lines) and `TabularSurfaceHeaderSeparatorBrush` (header separators and bottom rule) use the Fluent `DividerStrokeColorDefault` value and `TabularSurfaceOuterBorderBrush` (the control border) uses `CardStrokeColorDefault`; High Contrast uses `SystemColorWindowTextColor` for both. `TabularSurfaceHeaderSeparatorBrush` is the header line brush: overriding `TabularSurfaceGridLineBrush` does not recolor header lines, so override both to recolor every line. The stock style always provides the key; if it is missing, High Contrast falls back to `SystemColorWindowTextColor` and every other theme draws no grid line.
`TableView.xaml` does not redeclare the `TabularSurface*` brush keys: generic-style fallbacks would shadow the canonical palette and bypass its HighContrast system-color brushes. Consumers merge `TabularControlsResources` as documented by the sample.
Template naming unwraps realized presenters and reads public visible text or peer names, with an eight-level/32-element budget per cell. Explicit names and labels win. Name-based item search rechecks the realized public name instead of treating item text as an alias. Text edits capture only an existing cached cell peer; changed Value/Name notifications are deferred until teardown, guarded against recycling/newer values, and never cause peer creation. Space header activation is disarmed on completed focus loss, header replacement, template replacement and unload; autorepeat is consumed only while the same header remains armed. A column header's text is a string `Header`, or an untemplated non-`UIElement` header's `ToString` unless that is just the object's runtime class name (CsWinRT's default, typical of a header rendered by an implicit `DataType` template); otherwise — a `UIElement` header, one shown through `HeaderTemplate`/`HeaderTemplateSelector`, or a class-name `ToString` — it is named from its realized header content only, never from the grid line, sort indicator or resize gripper. Sort announcements use that same header name.
## Sort ownership and reconciliation
Sort has two front-ends and exactly one axis is ever in force; they reconcile rather than stack.
- `TableView.SortByColumn` (and header click) declares the control's own axis and publishes
`TableViewColumn.SortDirection`, which is what draws the header chevron.
- `TableViewSource.Sort` declares an axis on the source. The path overload
(`Sort(sortMemberPath, direction)`) names a property, so the control can match it against a
column's `SortMemberPath` and light that column's chevron. The delegate overload
(`Sort(keySelector, direction)`) is opaque — the key may not correspond to a column at all
(computed key, multi-field, custom comparer) — so no chevron is shown.
- `TableViewColumn.SortDirection` is read-only to apps. An app that cancels `Sorting` to order the
rows itself can make the chevron follow only by declaring that order with the path overload on
the bound `TableViewSource`.
**Axis identity on the source.** A path axis is keyed on its path, so re-sorting the same path
replaces it in place. A delegate axis is keyed on the delegate object's COM identity
(`ShapingPipeline::SetSort`): the same delegate instance re-sorts that axis in place and `None`
removes it, while any other instance, even one with identical logic, is a new, least significant
axis, and `None` with it removes nothing. Path and delegate axes never match each other, and the
control's own axis is keyed on column identity, so two columns sharing a `SortMemberPath` stay
separate.
Reconciliation rules:
- Sorting through the control clears any axis the app declared on the source.
- Declaring a sort on the source clears the control's axis. A path-declared axis that matches a
column lights that column and raises `Sorted` with it; otherwise every `SortDirection` is
cleared and `Sorted` carries a null column.
- `ClearSort()` clears every axis, including one the app declared.
**Single owner.** A `TableViewSource` has exactly one owning `TableView`. A second `TableView`
bound to a source another table already owns is refused and renders empty until the owner releases
it — by assigning a different `ItemsSource` or being unloaded. A table releases its source on
`Unloaded` and reclaims it on `Loaded` (reconciling its sort state then), so a navigation that loads
the new page before unloading the old one resolves on its own. While refused, a table cannot sort.
**Refused sorts.** `SortByColumn` and `ToggleSortDirection` return `false`, with no state changed,
when the sort cannot be applied: besides a canceled `Sorting` or an edit that will not close, a
column with `CanSort=False` (or with neither a sort member path nor a `CustomSortComparer`) is not
sortable, and a source containing a null row object or the same object on two rows has no row
identity to sort by. `CanUserSortColumns=False` gates only header clicks, not these methods.
Setting `CanSort=False` on the sorted column clears its sort through an internal path that skips
that gate, raising `Sorting`/`Sorted` exactly as `SortByColumn(column, None)` does. A `Sorting`
handler can cancel that clear, and it is skipped while the source is refused; the column then keeps
its sort, and `SortByColumn(column, None)` (allowed on a column still holding a sort, whatever its
`CanSort`) or `ClearSort` removes it. A column lit only by the app's `TableViewSource` sort, with
no sort installed by the table, keeps that state and raises no events, as when
`CanUserSortColumns` turns off.
Without this, the earlier-declared axis would silently outrank the later one while the chevron
advertised the loser. WPF splits the same way: `SortDescriptions` carry a property name, which is
how `DataGrid` matches a column and lights its arrow, while a data-layer sort with no property
name stays headerless.
## Grouping model
Grouping is declared on the **data source**, never on the control: `TableViewSource.GroupBy(...)`
installs a grouping verb, and `TableView` renders whatever shape the source publishes. The control
has no `GroupBy` property and no group-key API of its own, which is what keeps the shaping stack
(`TabularShaping` ← `ShapedItemsSource` ← `TableViewSource` ← `TableView`) one-directional: the
engine never learns what a `TableView` is.
**Projection shape.** A grouped source publishes one flat row vector in which group headers are
interleaved with their member rows (`ShapedItemsSource::ProjectionKind::Grouped`). There is no
second nesting level in the layout and no nested repeater: the header is simply another entry in
the same index space. `TableView::GetRowKindForItem` classifies an entry, and
`TableViewRowTemplateSelector` picks `TableViewGroupHeader` or `TableViewRow` accordingly, so
neither container carries an adaptive "am I the other kind?" branch.
**Group identity is the bucketing key, not the key object.** `BucketizeToGroups` asks for a
stable, non-empty string identity per key. Value-typed keys (`String`, `Int32`, `Int64`, `Guid`,
`Boolean`, enums) resolve through a built-in identity; anything else requires the
`GroupBy(keySelector, groupIdentitySelector)` overload. An identity that is empty, non-string,
throwing, or that collides across two genuinely different keys **throws**
`hresult_invalid_argument` rather than degrading to a flat projection — the same fail-fast contract
row identity uses. A `null` key and an empty-string key are rejected on the same terms
(`ValueKey::TryGetStablePropertyKey` is called with `rejectEmptyString`), so grouping on a
nullable or blank-able property needs an app-supplied fallback label. Degrading silently would
ship apps whose grouping mysteriously does nothing, with no diagnostic. (Two distinct key
instances *may* map to one identity when the app supplied a `groupIdentitySelector`; supplying one
is the opt-in that makes the collapse intentional.)
Only **row** identity degrades instead of throwing, and the asymmetry is deliberate: a row identity
the engine cannot derive is a property of the app's data that the app may not control, so the
`Unshaped` mirror still shows every row — it just cannot be sorted by the control, and
`SortByColumn`/`ToggleSortDirection` return `false` up front; a bad *group* identity
comes from the `GroupBy(...)` selector the app just wrote, and an ungrouped table is an outcome it
would not notice.
**Composition order is meaningful.** A sort declared **before** `GroupBy` orders the groups; a
sort declared **after** `GroupBy` orders rows within each bucket
(`ApplySort(rows, -1, GroupOrder())` for the group axis, then `ApplySort(bucket.Items,
GroupOrder(), -1)` per bucket). A filter runs first in either case, and a group whose last member
is filtered out ceases to exist.
**Expansion is intent keyed by identity, held outside the groups.** `RowExpansionModel` (layer 1)
stores only the *exceptions* to a default, so `ExpandAllGroups`/`CollapseAllGroups` move the
default in O(1), a group that appears later inherits the current default rather than someone
else's stale intent, and a collapse survives a re-sort, a re-filter or a regroup that re-mints
every `ShapedGroup`. Storing `IsExpanded` on the group object instead is what used to lose a
collapse across a re-sort. `RetainOnly` prunes intent for identities that no longer exist so the
store cannot grow without bound across changing data sets.
**Toggle path.** `TableViewGroupHeader` raises `ToggleRequested`; the owning container forwards it
to `TableView::ToggleGroupExpansion`, so the header stays free of `TableView` plumbing and is
testable on its own. The whole band is the toggle target (matching `ListView`/`TreeView`) rather
than a nested button, so there is one interactive element and one automation story. Because
expanding or collapsing re-publishes the projection, the realized header the gesture started on is
gone by the time the work runs: expansion is queued **by identity** with a generation stamp
(`QueueGroupExpansionByIdentity` / `ApplyGroupExpansionByIdentity`), and focus is captured and
restored by identity too (`CaptureContainerFocusForRestore` /
`RestoreContainerFocusIfPending`), so focus does not drop to the top of the
table on every collapse. The restore covers group headers and hierarchical data rows. For a header the restored focus state follows the gesture (`Keyboard` for a key, `Pointer` for a click), and a restore after a pointer click on the header does not scroll it into view. A data row is restored only from keyboard or programmatic focus, or when `Multiply` expands its subtree (a keyboard gesture, whatever state the row's focus had), always with `Keyboard` state, and only when the reset recycled its container. The restore is skipped when focus has meanwhile moved outside the table or the host window is hidden. A pointer click on a band focuses the header on **release** inside the band, before the toggle, and only when the band still shows the group that was pressed; if committing an open edit re-targets the header to another group, the click is consumed without toggling. A press dragged off the band does nothing.
**Header presentation.** `TableViewGroupHeader` is a templated `ContentControl`, not a code-built
visual tree: the chevron gutter (`PART_ExpanderGutter` / `PART_ExpanderIcon`) and the themed band
live in the control-owned `ControlTemplate`, while `TableView.GroupHeaderTemplate` fills only the
content region — so an app template cannot accidentally drop the expander. Its `Content` is a
`TableViewGroupInfo` projection that is updated **in place** and raises `PropertyChanged`, so
recycling a header does not re-evaluate every binding in the template. `KeyText`/`ItemCountText`
are computed lazily through a `DecimalFormatter`, so a template that binds only `Key`/`ItemCount`
pays nothing for them.
Under grouping, a hierarchical root row's lead cell is indented by the group header's expander
gutter plus its spacing (`TableViewGroupExpanderSize` + `TableViewGroupExpanderSpacing`, 32px by
default). The header's own `Padding` is not part of the indent, so the root row's chevron starts
`Padding.Left` (8px by default) left of the group key text rather than under it.
**Interaction with selection.** Group headers share the flat projection's index space with data
rows but are **not** selectable. `Select(index)` on a header index is rejected rather than coerced
(without that, the internal grouped entry would be handed to the app as `SelectedItem`), and
selection-follows-focus leaves the existing selection alone when keyboard navigation lands on a
header, resuming on the next data row. The consequence an app must know: under grouping,
`SelectedIndex` is an index into a row space that *includes* headers, so it is not an index into
`ItemsSource`.
**Accessibility.** `TableViewGroupHeaderAutomationPeer` is separate from
`TableViewRowAutomationPeer` because the header is its own container: there are no `GridItem`
coordinates for a band spanning every column, and `ExpandCollapse` is advertised unconditionally —
a non-expandable group reports `LeafNode` rather than dropping the pattern, so an AT client never
sees the pattern appear and disappear as data changes.
**Known limitations.** A source change under grouping rebuilds the projection rather than splicing
it (the flat incremental fast path is explicitly disabled by `ClearFlatRowIdentityTracking`), so
grouping is O(N) per collection change. There is no per-group programmatic expand/collapse by key,
only the bulk commands plus the header's own gesture and UIA pattern. Grouping is single-level:
`TableViewGroupInfo.Level` exists for the shape but is always `0` in v1.
# Styling model
Styling is layered so the display-only base stays minimal while a richer surface can grow
additively — the realized row/cell/header are already styleable targets (`TableViewRow` is a
public `Control`; cell wrappers are `Border`s; column headers are public, sealed
`TableViewColumnHeader` controls that only the TableView creates), so the additions below
need no new base API or re-plumbing.
**v1 (this base):**
- `RowBackground` / `AlternatingRowBackground` — opt-in row banding (both null = unbanded, WPF
`DataGrid` parity; `AlternatingRowBackground` overrides odd rows only when set).
- `GridLinesVisibility` — row/cell gridline borders.
- `Density` — row min-height + built-in cell/header padding preset.
- `TabularSurfaces` theme resources — the theme-aware (light/dark/high-contrast) source of the
default brushes, re-resolved on theme and high-contrast changes.
- Typography theme keys — `TableViewCellFontSize` (14), `TableViewHeaderFontSize` (14) and
`TableViewHeaderFontWeight` (`SemiBold`). They do not vary with `Density`; an override in the
TableView's or an ancestor's `Resources`, or in `Application.Resources`, wins. The cell size is
cached per instance, so a later override applies after a theme, high-contrast or density change.
The header keys are `{ThemeResource}` setters in the `TableViewColumnHeader` default style, so a
header style's `FontSize`/`FontWeight` setter wins over them and nothing is written as a local value.
- `ColumnHeaderStyle` / `TableViewColumn.HeaderStyle` — `Style` for the templated
`TableViewColumnHeader` (`ContentControl`); the column's style wins. Both are layered over the
default header style, so they need not be `BasedOn` it. Pointer-over and pressed are `CommonStates`
(`Normal`/`PointerOver`/`Pressed`/`Disabled`) that change only the fill (the `TabularSurfaceHeader*`
brushes), so a custom `TableView.Foreground` survives hover; under High Contrast a separate
`HighContrastStates` group (`HighContrastNormal`/`HighContrastPointerOver`/`HighContrastPressed`)
swaps the text and glyph to the highlight-text brush. `SortStates`
(`Unsorted`/`SortAscending`/`SortDescending`) only show or hide the sort glyph. `MinHeight` and
`Padding` follow `Density` unless the applied header style sets them (an implicit header style's
setters for those two are overridden by the density values).
**Rich styling (later):** delivered as **per-element `Style` dependency properties**, matching WPF
`DataGrid` (`RowStyle` / `CellStyle` / `ColumnHeaderStyle`) — **not** a monolithic `TableStyle`.
`ColumnHeaderStyle` has shipped (above); `RowStyle` and `CellStyle` follow the same shape.
`RowStyle` targets the realized `TableViewRow`; `CellStyle` the per-cell container (the cell-wrapper
`Border`; cell *content* is customized via the column's template / `GenerateElementCore`, matching
WPF where `CellStyle` targets `DataGridCell`, not the content); `ColumnHeaderStyle` the generated
header. The header went first because its pointer, sort and typography visuals were the ones
built in code; the public `TableViewColumnHeader` type and `ColumnHeaderStyle` are preview API
pending API review.
**Why per-element Styles + theme resources, not a monolithic `TableStyle`:** per-element `Style`s
compose with the standard WinUI `Style`/`Setter`/`ControlTemplate` machinery and scale to any
property without a dependency-property explosion as selection/sort/hover/focus visuals arrive;
theme-awareness is owned by the `TabularSurfaces` resource dictionary (override a `TabularSurface*`
key at app/page scope and it re-resolves per theme); and a single swappable "table look" is already
expressible as a `Style` targeting `TableView` plus a `ResourceDictionary` of `TabularSurface`
overrides.
**Precedence:** the v1 convenience brushes (`RowBackground` / `AlternatingRowBackground`) are
retained as WPF-migration shortcuts and **augment** the Style DPs — an explicitly set convenience
brush wins over the corresponding `RowStyle` setter. If they prove redundant once `RowStyle` lands,
they can be obsoleted while the API is still `[MUX_PREVIEW]`.
- [Functional spec](TableView-functional-spec.md) and [API spec](../../api-specs/TableView/TableView-spec.md)
- Hierarchy: [API spec](Hierarchical-Data-Rows-API-Spec.md), [design](Hierarchical-Data-Rows-Parent-Key-Design.md) and [test plan](Hierarchical-Rows-and-Live-Shaping-Test-Plan.md)
# Architectural overview
`TableView` owns the column collection, row realization, header realization, scroll synchronization, frozen-column layout, density/theming state, keyboard navigation, and the automation surface. The architecture separates:
`TableView` owns columns, row realization, the header band, scroll sync, frozen columns, sort, selection and edit state, keyboard navigation and the UIA root. Data shaping lives in a separate stack:
- data realization: `ItemsRepeater`
- row presentation: `TableViewRow`
- cell generation: `TableViewColumn`
- header presentation: generated header cells in `PART_HeaderHost`
- coordinate math: `GridCoordinateHelper` (internal)
- accessibility: dedicated automation peers
```text
TableView / RowMetadataProvider consume visible rows and metadata
↑
GroupedSourceAdapter / HierarchicalSourceAdapter publish the flat visible projection
↑
ShapedItemsSource observes the source; builds shape and indexes
↑
TableViewSource exposes public shaping verbs
```
Arrows show data flow, not ownership: `TableView` holds a `TableViewSource`, which owns a `ShapedItemsSource`, which creates the adapter for the active shape. `TabularShaping` (`ShapingPipeline`, `RowExpansionModel`, `ParentKeyIndex`, `SortDirection`) provides the headless algorithms used below `TableView` and has no dependency on the control; the adapters depend only on it. An `ItemsSource` that is not a `TableViewSource` is wrapped in one internally, so sorting works over any supported collection.
## Key public components
**`TableView`** — `Control`, `[contentproperty("Columns")]`. DPs: `ItemsSource`, `Columns` (get-only `IVector<TableViewColumn>`), `HeadersVisibility`, `GridLinesVisibility`, `RowBackground`, `AlternatingRowBackground`, `EmptyTemplate`, `Density`, `IsReadOnly` (default `true`).
| Type | Kind | Role |
|---|---|---|
| `TableView` | unsealed `Control` | The table. Content property is `Columns`. |
| `TableViewColumn` | unsealed `DependencyObject` | Width, header, sort, freeze, read-only, cell factory (`GenerateElementCore`). |
| `TableViewTextColumn`, `TableViewTemplateColumn` | unsealed columns | Text cells and templated cells. |
| `TableViewRow` | unsealed `Control` | Realized data row. `IsSelected`, `Level`, `IsExpandable`, `IsExpanded` are read-only. |
| `TableViewGroupHeader` | unsealed `ContentControl` | Realized group band. Raises `ToggleRequested`. |
| `TableViewColumnHeader` | sealed `ContentControl` | One per column, created only by `TableView`. |
| `TableViewCellsPanel` | unsealed `Panel` | Hosts header and row cells and keeps columns aligned. |
| `TableViewSource` | sealed | `Filter`, `Sort`, `GroupBy`, `ParentBy`, live shaping. |
| `TableViewGroupInfo` | `INotifyPropertyChanged` | Group-header content. |
| Five automation peers | unsealed | See [Accessibility](#accessibility). |
**`TableViewColumn`** — `DependencyObject`, `[contentproperty("Header")]`. Members: `Header`, `HeaderTemplate`, `HeaderTemplateSelector`, `Width` (`GridLength`), `MinWidth` (`20`), `MaxWidth` (infinity), `ActualWidth` (get-only), `FrozenEdge`, `Visibility`, `IsReadOnly` (default `false`), `GetOwningTableView()`, `GenerateElement(Object)`, overridable `GenerateElementCore(Object)`, `GenerateEditingElement(Object)`, overridable `GenerateEditingElementCore(Object)` and `GetEditingPropertyPathCore()`.
**`TableViewTextColumn`** — `TableViewColumn` that generates text cells. `Binding` is a plain CLR property. Its editing element is a `TextBox` bound with a clone of `Binding` forced to `TwoWay` + `UpdateSourceTrigger=Explicit`. The built-in text column's editor `TextBox` takes `AutomationProperties.Name` from the column header text.
**`TableViewTemplateColumn`** — `TableViewColumn` that generates templated cells. DPs `CellTemplate` and `CellEditingTemplate`.
**`TableViewRow`** — `Control` representing one realized item row. `GetOwningTableView()`.
**`TableViewGroupHeader`** — `ContentControl` representing one realized group-header band under a
grouped source. DPs `IsExpanded`, `IsExpandable`; event `ToggleRequested`
(`TableViewGroupHeaderToggleRequestedEventArgs.GroupKey`). Template parts `PART_ExpanderGutter`,
`PART_ExpanderIcon`; visual states `CommonStates`, `ExpansionStates`, `ExpandabilityStates`.
**`TableViewGroupInfo`** — read-only `INotifyPropertyChanged` projection bound by a
`GroupHeaderTemplate`: `Key`, `ItemCount`, `Level`, `IsExpandable`, `IsExpanded`, and the
culture-formatted `KeyText` / `ItemCountText`. Updated in place across recycling.
**Automation peers** — `TableViewAutomationPeer`, `TableViewRowAutomationPeer`, `TableViewCellAutomationPeer`, `TableViewColumnHeaderAutomationPeer`, `TableViewGroupHeaderAutomationPeer` (providers: `IGridProvider`, `ITableProvider`, `ISelectionProvider`, `IItemContainerProvider`, `IGridItemProvider`, `ITableItemProvider`, `ISelectionItemProvider`, `IValueProvider`, `IInvokeProvider`, `IExpandCollapseProvider`).
**Enums** — `TableViewFrozenEdge` (`None`/`Leading`/`Trailing`), `TableViewHeadersVisibility` (`None`/`Column`), `TableViewGridLinesVisibility` (`All`/`Horizontal`/`None`/`Vertical`), `TableViewDensity` (`Compact`/`Standard`/`Comfortable`), `TableViewEditingUnit` (`Cell`/`Row`), `TableViewEditAction` (`Commit`/`Cancel`/`Discard`).
# Editing
Editing is split into two files on purpose:
- **`TableView_Editing.cpp`** owns the edit **state machine** and is reachable entirely through the programmatic API.
- **`TableView_EditingInput.cpp`** owns the **policy** of which user gestures map onto that API.
The split is what lets a future keyboard or selection layer change gesture routing — which key commits versus moves, whether `Tab` advances the cell — without reopening the state machine, and it keeps the state machine testable without synthesizing input.
## Edit state machine
`EditState` is explicit: `None → Beginning → {None, Editing}` and `Editing → Ending → {Editing, None}`. `Ending → Editing` is the veto / validation-failure / deferral path — the edit stays open. It replaces a set of independent booleans whose combinations encoded the real invariants only implicitly.
## Gesture layer
Begin-edit is driven from **`PointerPressed` with click-count tracking**, not a `DoubleTapped` handler. Marking `PointerPressed` handled suppresses XAML's gesture recognizer, so it never produces `Tapped`/`DoubleTapped` for that element — and a row that participates in selection has to mark the press handled. A `DoubleTapped` handler would therefore work today and silently stop working the moment a selection layer lands. The handler is registered with `handledEventsToo` so it survives that.
The same handler is the only place a pointer establishes the **current cell**, which is also what makes keyboard editing reachable. A primary-button press on a cell drills its row to cell level and focuses **that cell**, so keyboard focus, the UIA focused element, and the current cell all name the cell under the pointer. A press on the row's empty strip, past the last column, focuses the row itself.
Selection is applied on **release**, and only when the release lands on the row that was pressed. A press that is dragged off and released elsewhere selects nothing. A secondary-button (right) press never selects, because it is the context-menu gesture.
Cell resolution walks up from `OriginalSource` to the cell wrapper (`TableViewCell`, a `Grid` whose `Tag` carries the column) and on to the `TableViewRow`. When the walk cannot reach a wrapper — which happens once the row has focus and the press arrives with the row itself as source — it falls back to hit-testing at the pointer position.
## Value transfer and rollback
Editing bindings use `UpdateSourceTrigger=Explicit`, so the typed value lives in the editor until the control pushes it. That is what lets a cancel restore something and a validation failure hold the edit open. On commit the control calls `UpdateSource()` on the binding expressions discovered from the editing element; validation runs **after** the write, and a blocked commit undoes it.
A forced close (the source changes under the edit, or the control turns read-only) commits a valid pending value while the edited item is still in the source and passes the filter, and reports `Cancel` on a failed write or validation error. Because the pending value is still in the editor, a forced close first transfers it to the item, then validates; a value that fails validation is rolled back to the pre-edit value, so the item never keeps a value the control rejected. Inside a layout pass (a recycled or rebuilt row) restoring the editor would re-enter layout, so that rollback is posted to the dispatcher, and it is skipped if the item's value has changed in the meantime — another edit of the same cell is open, or the property no longer holds the rejected value — so a newer write is never overwritten. If the item was removed, filtered out, or `ItemsSource` was replaced, it reports `Cancel` and writes nothing, as WPF `DataGrid` does; an item that is only under a collapsed group or node has not been removed. A forced close during a pending commit deferral can finish as `Cancel` without a second `CellEditEnding`.
There is no item-owned transaction interface in this release (`ITableViewEditableItem` is deferred with row editing). The control snapshots the value at the edited property path, falling back to scraping the editor's bindings — inherently incomplete for an arbitrary editor, so the inability to capture anything is recorded and reported rather than silently ignored.
## Re-entrancy rules (load-bearing)
Row recycling, cell rebuild and dependency-property change callbacks can all run inside a layout pass or a framework callback. Mutating focus from there re-enters XAML and trips the re-entrancy assertion (`0xc0000420`). Therefore:
- `TableViewRow::EndCellEdit` (restores the display child **and** moves focus) is only used on the interactive close paths.
- `TableViewRow::AbandonCellEdit` restores the display child but never touches focus, and is what `TableView::TerminateEditWithoutVisualRestore` drives from `OnRowElementClearing`, `RebuildCells`, `OnIsReadOnlyPropertyChanged` and `OnItemsSourcePropertyChanged`.
- The cell **restamp** fast path deliberately does *not* tear the edit down — it reuses the existing cells, and closing the edit there would make it impossible to keep an editor open through the re-measure that installing the editor itself provokes. Only the destructive rebuild path closes it.
- The focus-loss commit is posted to the dispatcher and re-evaluated once focus settles; deciding synchronously closes the edit inside the very gesture that opened it.
The full surface, including event args and enums, is in `TableView.idl`, `TableViewSource.idl` and `TabularShaping/SortDirection.idl`, and in the [API spec](../../api-specs/TableView/TableView-spec.md).
## Key internal components
- **`PART_HeaderScroller`** — horizontal-only header scroller synchronized with the body. Found as `PART_HeaderHost`'s nearest `ScrollViewer` ancestor; the name is for readability only.
- **`PART_HeaderHost`** — `TableViewCellsPanel` header host rebuilt from the non-null columns.
- **`PART_BodyScroller`** — body scroll presenter and source of horizontal offset. Found as `PART_RowsRepeater`'s nearest `ScrollViewer` ancestor; the name is for readability only.
- **`PART_RowsRepeater`** — vertical row realization surface.
- **`PART_CellsHost`** — row-local `TableViewCellsPanel` cell host.
- **`TableViewCellsPanel`** — the custom `Panel` used for `PART_HeaderHost` and each row's `PART_CellsHost`; its `MeasureOverride` reports each cell's measured width and its `ArrangeOverride` places cells at the column's resolved `ActualWidth` (see *Column alignment*).
- **Cell wrappers** — per-row/per-column `Border` elements that host generated cell content, carry gridline/background visuals, and are arranged by the hosting `TableViewCellsPanel` at the owning column's `ActualWidth`.
- **Frozen-column translation/clipping layer** — pins leading columns while clipping non-frozen content out of the pinned band.
- **`GridCoordinateHelper`** — internal C++ helper for row/column ↔ flat-index and focus-navigation math used by keyboard navigation; not projected (no public runtimeclass).
- **`TableViewRowTemplateSelector`** — picks the container kind for an entry in the projection: a `TableViewGroupHeader` for a group entry, a `TableViewRow` for a data row.
- **`RowExpansionModel`** — internal layer-1 store of expand/collapse *intent*, keyed by group identity and holding only the exceptions to a default. Carries no XAML or tabular vocabulary, so hierarchy can reuse it unchanged.
| Component | Role |
|---|---|
| `TableViewCell` | Cell wrapper: a single-child `Grid` (`Border` is sealed) whose `Tag` is the column. The cell's keyboard and UIA focus target. |
| `TableViewRowTemplateSelector` | `[MUX_INTERNAL]`. Picks the row or group-header template, so `ItemsRepeater` keeps one recycle pool per kind. |
| `TableViewRowInfo`, `RowMetadataProvider` | Row kind, level and position metadata, and the row and group identity contract. |
| `SortMemberPathResolver` | One binding-based path evaluator for both sort front-ends, so a path that displays also sorts. |
| `GridCoordinateHelper` | Row/column ↔ index math for keyboard navigation. Not projected. |
| `TableView_ContextMenu.cpp`, `TableViewToolTipHelpers.h` | Context-flyout resolution and cell tooltips. |
| `ResizeGripper` | Framework-internal drag primitive (see [Column resize](#column-resize)). |
| `ShapedItemsSource` | Live projection: rows, group buckets (`ShapedGroup`), identity index (`RowIdentity`), item subscriptions (`LiveShapingTracker`). |
| `GroupedSourceAdapter`, `HierarchicalSourceAdapter` | Materialize groups or a tree into one flat row list. |
| `ShapingPipeline` | The ordered sort, filter and group verbs a caller declared. |
| `RowExpansionModel` | Expand/collapse intent for groups and tree nodes. |
| `ParentKeyIndex` | Validated, immutable tree over the unfiltered source for `ParentBy`. |
## Visual tree
```text
TableView
└─ Border (chrome; CornerRadius = ControlCornerRadius)
└─ Grid: Row 0 = Auto (header band), Row 1 = * (body band)
├─ Border PART_HeaderRow header background + 1px bottom rule
│ └─ ScrollViewer PART_HeaderScroller no user scrolling; H offset driven from the body
│ └─ TableViewCellsPanel PART_HeaderHost one TableViewColumnHeader per column
└─ ScrollViewer PART_BodyScroller H + V, auto scrollbars
└─ Grid PART_BodyContent
├─ ItemsRepeater PART_RowsRepeater vertical StackLayout, VerticalCacheLength 2.0
└─ ContentControl PART_EmptyStatePresenter overlay, Collapsed unless empty
TableViewRow
└─ Grid
├─ Border PART_RootBorder row background
│ └─ Grid
│ ├─ ContentPresenter PART_CellForegroundPresenter foreground for Selected* states
│ │ └─ Grid
│ │ ├─ TableViewCellsPanel PART_CellsHost one TableViewCell per column
│ │ └─ Border PART_RowExpanderGutter > FontIcon PART_RowExpanderIcon
│ └─ Rectangle PART_SelectionIndicator leading-edge pill, Opacity 0 unless Selected*
└─ Border PART_GridLineBorder 1px bottom gridline, not hit-testable
```
`RebuildHeaders` and `RebuildCells` create one header or cell per non-null column, in `Columns` order. A cell hosts the element from `TableViewColumn.GenerateElement(dataItem)`. `TableView` sets `PART_RowsRepeater.ItemTemplate` (the selector needs an owner XAML cannot supply) and owns its `ItemsSource`.
**Header focus survives a rebuild.** `RebuildHeaders` restores header focus by column identity through `RestoreHeaderFocus`. If the focused column was removed, focus falls back to the first visible header, else to the body. Separately, hiding the focused column is handled by `OnColumnVisibilityChanged`, which moves focus to the next visible header, or to the previous one when it was last.
# Performance considerations
- **No column virtualization.** Each realized row renders a cell per non-null column, which suits tables of about 5 to 50 columns, not spreadsheet scale (100+).
- **Row virtualization.** Rows in the viewport plus one viewport of cache above and below (`VerticalCacheLength` 2.0) are realized. `StackLayout` handles variable row heights.
- **Grouping** rebuilds the projection on each source change, O(N).
- **Live shaping** coalesces to one reshape per dispatcher turn; each reshape re-reads every item.
- The functional spec targets at least 30 FPS scrolling at about 500 rows.
# Debugging and telemetry considerations
- **Trace keyword:** `KEYWORD_TABLEVIEW` (`0x0000000000020000`) in `Telemetry/MuxcTraceLogging.h`. Under `MUXCONTROLS_TABULAR`, Tabular uses its own provider names (`Microsoft.UI.Xaml.Controls.Tabular`, `.Perf`, `.Debug`).
- **Telemetry:** `TableViewTelemetry.cpp` writes one `TableView_SessionSummary` per loaded session (operation counts, at `Unloaded`) and a local-only `TableView_Error`.
- **RuntimeProfiler:** `ProfId_TableView`, marked in the constructor.
- **Test in production:** `TableViewTip.h`/`TableViewTipTests.h` define a setup test per template application and a test per key operation. `TableView.TestInProduction.props` picks the TiP library or a stub.
## Gesture recognizer handling
- **Begin edit** uses `PointerPressed` with click counting and `handledEventsToo`, not `DoubleTapped`: a row that takes part in selection marks the press handled, which suppresses `Tapped`/`DoubleTapped`.
- **`ResizeGripper`** reads manipulation events, so touch, pen and multi-pointer arbitration come from the gesture recognizer. `ManipulationMode` (one translate axis) is set in the constructor, so a failed style load cannot disable resize. Because the gripper moves with the edge it drags, deltas are measured in `ManipulationContainer`, which `TableView` sets to itself (the primitive falls back to the `XamlRoot` content), not the gripper. Only the mouse is captured, so touch can still pan an ancestor `ScrollViewer`.
# Implementation details
## Column layout and width sizing
Width resolution is a **table-level** decision, made once per layout pass in `TableView::MeasureOverride` (engine in `TableView_Layout.cpp`):
1. **Measure and cache.** Each realized `TableViewCellsPanel` measures its `Auto` cells unconstrained and caches the width per column (`MeasuredWidthForColumn`). If the column is narrower, the cell is re-measured at the column width so ellipsized content and the right gridline stay in the slot. `Pixel` and `*` cells are measured once at the resolved width, collapsed columns at zero, and an open editor at the column width without feeding `Auto`.
2. **Resolve.** `ResolveColumnWidths` takes the largest cached width per column across the header and realized rows and writes `ActualWidth`. `*` columns share the viewport left after fixed columns, with `MinWidth`/`MaxWidth` clamping and re-division (WPF's `ComputeStarColumnWidths` shape).
3. **Share.** If a width changed, `TableView` invalidates the header and row panels directly.
4. **Arrange.** Each panel places cells at `ActualWidth`. Cells have no `Width` binding.
Cells only measure and cache; they never push a width. Every panel reads the same `ActualWidth`, so columns stay aligned without cross-row coordination. The default `Width` is 120 px, `MinWidth` 20 and `MaxWidth` unbounded.
**`Auto` rows are grow-only.** For an `Auto` column, `ActualWidth` is the maximum of the current header measurement and a grow-only maximum measured across realized row cells. The row maximum resets only at a data-set boundary, so scrolling never narrows a column; the header measurement remains current and is zero while headers are hidden, so a shorter header or turning off sorting can narrow it.
**Data-set boundaries** reset the row maximum: an `ItemsSource` change or a `Reset` of `Columns` for every column, and a `CellTemplate` or `Header` change for that column. Filtering, an in-place items `Reset`, `Density`, `MinWidth`/`MaxWidth` and adding or removing a column are not boundaries.
**`*` needs a finite width.** The body is measured at infinite width, so the engine reads `ScrollViewer.ViewportWidth` and re-resolves on the body scroller's `SizeChanged`.
## Column resize
Resize is split between a primitive and the table. **The primitive owns** the gesture: hit target, cursor, visual states, `IsDragging`, a 0.5 DIP deadband, rejection of non-finite deltas, re-entrancy guards and cancel. It reports a logical delta (positive grows in reading order; only the horizontal axis mirrors under RTL). **The table owns** every policy: whether a column gets a gripper (`CanUserResizeColumns` and `CanResize`, both default `true`), its width (`TableViewResizeGripperWidth`, 8), the anchor, clamping, writing `Width`, reverting on cancel and the UIA announcement. The gripper owns no width state, because `TableViewColumn` already does.
**Protocol.** `BeginDrag()`, `DragDelta` (`Delta`, `TotalDelta`), then `EndDrag(canceled)`. `DragCompleted.Canceled` marks a torn-down gesture (Escape, lost capture, disabled, unloaded); the table then restores the authored `GridLength`, so a canceled drag never pins an `Auto` or `*` column. The drag methods are public so keyboard resize raises the same events.
**Naming.** `DragOrientation` is the axis the drag is measured along, the opposite of the Windows Community Toolkit's `SizerBase.Orientation`, hence the distinct name. `OwnerName` qualifies the peer's name ("Price, Resize gripper") and is declared rather than read from `Tag`, which a host may already use.
**Keyboard.** The column header is the keyboard target; the gripper is not a tab stop. `Alt+Left`/`Alt+Right` on a header call `TryKeyboardStep`, which owns direction, the RTL mirror, `KeyboardIncrement` (8) and the `Shift` multiplier (4×). `Alt` is the only resize modifier, as in WPF.
**Bounds.** `ResizeBoundsForColumn` applies three rules derived by measuring WPF `DataGrid`:
1. **Donor.** Only a resizable `*` column after the dragged one can give width, because only `*` columns are re-divided.
2. **Direction.** `*` columns before the divider are frozen at their rendered width (`FreezeColumnsBeforeResize`).
3. **Ownership.** A `*` column can only take from another `*` column. A `Pixel` or `Auto` column may still shrink with no donor.
| Condition | Outcome |
|---|---|
| No `*` column | Unbounded; the body scrolls |
| A donor exists | Capped at viewport minus reserved widths |
| No donor, dragged column is `*` | Pinned: no gripper |
| No donor, dragged column is `Pixel`/`Auto` | Shrink-only |
A column with `MinWidth` ≥ `MaxWidth` is also pinned. `IsColumnResizePinned` is shared by the bounds and gripper visibility. It is re-evaluated when `Width`, `MinWidth`, `MaxWidth`, `Visibility` or `CanResize` changes; size changes apply on `DragCompleted`, not mid-drag.
## Row virtualization and recycling
On recycle, `ItemsRepeater` re-points a row's `DataContext`, and cells update **reactively** through inheritance and bindings. Cells are never re-stamped, because changing a live cell during the repeater's measure re-enters layout and fails fast.
- `TableViewTextColumn` binds `TextBlock.Text` to its `Binding`.
- `TableViewTemplateColumn` binds `ContentPresenter.Content` to the cell wrapper's inherited `DataContext`, because a `ContentPresenter` sets its own `DataContext` to its `Content`.
- **Custom columns** must bind to the inherited `DataContext` and not set a local one. `dataItem` is for initial setup only; content that bakes it in (or initializes once, in `Loaded` or with `x:Bind` `OneTime`) shows the previous item after a scroll.
`RebuildCells` runs only for structural changes (columns, templates, density). A normal recycle keeps the existing cells (they update through bindings) and refreshes per-row chrome (frozen pinning, gridlines, banding, tooltips, hierarchy and selection state). Exception: when cell automation peers exist and the row is re-bound to a different item, the cells are cleared and rebuilt.
## Scrolling: sticky headers and frozen leading columns
**Sticky headers.** `PART_HeaderScroller` has user scrolling disabled. `TableView` calls `ChangeView` on it from `PART_BodyScroller.ViewChanged`. The sync is one-way and skips differences under 0.5 px.
**Frozen leading columns.** Frozen cells are counter-translated against the horizontal offset through `UIElement.Translation` (enabled with `ElementCompositionPreview.SetIsTranslationEnabled`), and non-frozen cells are clipped out of the pinned band. Only the contiguous prefix from column 0 freezes. Column reorder, when implemented, must not assume this approach.
## Shaping: sort, filter, group and hierarchy
Shaping is declared on the **source**, never on the control. `TableView` renders whatever its `TableViewSource` publishes.
**Single owner.** A `TableViewSource` has one owning `TableView`. A second table bound to it is refused without an error and renders empty, and cannot sort, until the owner releases it by changing `ItemsSource` or unloading. Tables release on `Unloaded` and reclaim on `Loaded`, so page navigation resolves on its own.
**Row identity** is the item's object identity (under `ParentBy`, its node key). Selection, and `Tab` re-entry into the body from outside the table, follow the item across a reshape. Focus already in the body stays at its row index (see [Keyboard navigation](#keyboard-navigation)). With no shaping verb, a null item or one object on two rows still displays, but control sorts return `false`; with a verb in force, the same data throws `E_INVALIDARG`.
**One control sort axis in force.** The two front-ends reconcile instead of stacking:
- `TableView.SortByColumn`, `ToggleSortDirection` and header clicks declare the control's axis, keyed on column identity so columns sharing a `SortMemberPath` stay separate. The column's read-only `SortDirection` draws the chevron.
- `TableViewSource.Sort(path, …)` lights the column whose sort path matches. `Sort(keySelector, …)` is keyed on the delegate instance and shows no chevron: the same instance re-sorts or, with `None`, removes its axis, while any other instance with `None` changes nothing and with a direction adds a tie-breaker.
- Sorting through the control clears app-declared axes, and a source sort clears the control's axis. Otherwise an older axis could outrank a newer one while the chevron showed the loser.
- `TableViewSource` may declare several ordered source axes; the first declared is primary, as in WPF `SortDescriptions`.
A sort declared before `GroupBy` orders the groups; one declared after orders rows within each group. Filtering runs first, and an emptied group disappears.
**Group identity fails fast.** Each group key needs a stable, non-empty string identity. Boxed scalars (`IPropertyValue`) have a built-in one; other keys need `GroupBy(keySelector, groupIdentitySelector)`. A null, empty or throwing identity, or two keys colliding without a selector, throws `E_INVALIDARG` when the projection is built. Row identity degrades instead, deliberately: it comes from app data the app may not control, while a bad group identity comes from a selector the app just wrote, and a silently ungrouped table gives no diagnostic.
**Expansion is intent keyed by identity.** `RowExpansionModel` stores only exceptions to a default, outside the groups, so `ExpandAllGroups`/`CollapseAllGroups` just move the default, new groups take the current default, and a collapse survives a re-sort, filter or regroup. Each such move still runs a synchronous projection rebuild over all groups and rows on the UI thread. `RetainOnly` prunes identities that no longer exist.
**Toggle.** The whole group band is the toggle target. Re-publishing destroys the realized header, so `TableView` queues the expansion by identity with a generation stamp, and restores focus by identity so a collapse does not drop focus to the top. A header's `Content` is a `TableViewGroupInfo` updated in place.
**Hierarchy.** `ParentBy` builds a `ParentKeyIndex`. Rows start collapsed, sort applies per sibling set, a filter keeps matches plus ancestors, and `GroupBy` buckets the roots. See the [hierarchy design](Hierarchical-Data-Rows-Parent-Key-Design.md).
**Live shaping** (`IsLiveShaping`) reshapes when an item's sort key, group key, filter result or parent changes, and holds reshapes while a cell is edited. Off-thread `PropertyChanged` is marshalled to the UI thread; collection changes must be raised on it.
## Selection
Selection is single, row-scoped, independent of editing, and follows keyboard focus. Group headers share the row index space but are not selectable: `Select` on a header index is rejected and selection-follows-focus skips headers. Under grouping, `SelectedIndex` therefore counts headers and is not an `ItemsSource` index. Future `Multiple`/`Extended` modes will append enum values and a separate `MultiSelectStates` group, so existing templates stay valid.
## Editing
Editing is cell-scoped and starts only from user gestures (double-click, or `F2`). `TableView.IsReadOnly` defaults to `true`. The public surface and the deferred features (`BeginEdit`, `RowEditEnding`, `ITableViewEditableItem`, public editing hooks) are in the API spec's [Cell editing scope](../../api-specs/TableView/TableView-spec.md#cell-editing-scope).
`TableView_Editing.cpp` owns the state machine, reachable entirely through the programmatic API; `TableView_EditingInput.cpp` maps gestures onto it. Gesture routing can change without touching the state machine, which is testable without input. `EditState` is `None → Beginning → {None, Editing}` and `Editing → Ending → {Editing, None}`; `Ending → Editing` is the veto path.
**Gesture layer.** A primary press on a cell makes it the current cell and focuses it, so keyboard, UIA and current cell agree. Selection applies on release over the pressed row. A right press never selects.
**Value transfer.** Editing bindings use `UpdateSourceTrigger=Explicit`; commit calls `UpdateSource()`, validates after the write and undoes a blocked write. A forced close (source change, or read-only) commits a valid value while the item is still in the source and passes the filter, else reports `Cancel` and writes nothing, as WPF does. A rollback inside layout is posted, and skipped if the value changed meanwhile.
**Re-entrancy rules.** Recycling, rebuilds and property callbacks can run inside layout, where moving focus fails fast (`0xc0000420`):
- `TableViewRow::EndCellEdit` restores the display element and moves focus; it runs only on interactive close paths.
- `TableViewRow::AbandonCellEdit` never touches focus. `TableView::TerminateEditWithoutVisualRestore` uses it from `OnRowElementClearing`, `RebuildCells`, `OnIsReadOnlyPropertyChanged` and `OnItemsSourcePropertyChanged`.
- The cell restamp path keeps the edit open, because installing the editor itself triggers a re-measure.
- The focus-loss commit is posted and re-evaluated once focus settles.
## Keyboard navigation
Keyboard handling follows the WAI-ARIA treegrid model. `TableView` handles bubbling `KeyDown`, so hosted controls see keys first. The table has **two tab stops**, the header band and the body, which share one column cursor. Horizontal keys mirror under RTL.
| Where | Key | Behavior |
|---|---|---|
| Header | `Left`/`Right` | Move between visible headers, no wrap. |
| Header | `Down` / `Up` | First row / stay. |
| Header | `Enter`, `Space` | Sort. `Space` fires on key-up, so auto-repeat cannot re-sort. |
| Header | `Alt+Left`/`Alt+Right` | Resize. |
| Row | `Up`/`Down`, `PageUp`/`PageDown` | Move at the same level (row or cell); `Up` from row 0 enters the header. Paging works like `ListView`. |
| Row | `Home`/`End` | First or last row. |
| Row | `Right`/`Left` | Flat row: drill into the first cell / nothing. Tree row: expand, else drill in / collapse, else go to parent. |
| Row | `Multiply` | Expand the subtree. |
| Group header | `Enter`, `Space` / `Right`/`Left` | Toggle / expand / collapse. |
| Cell | `Left`/`Right`, `Home`/`End` | Move within the row, no wrap; `Left` on the first cell returns to the row. |
| Cell | `Ctrl+Home`/`Ctrl+End` | First or last cell of the table. |
| Cell | `Enter` / `Escape` | Into the cell's control / back out. |
| Row, cell | `F2` | Begin edit. |
| Row, cell | `Space` / `Ctrl+Space` | Select / toggle the row. `Ctrl` with a navigation key moves without selecting. |
- **Re-entry.** `Tab`/`Shift+Tab` into the body from outside the table returns to the item that last had focus, even after a sort or filter, at the same level. If it is not available, entry lands on the first or last enabled row in the viewport, never a cache row. From the header band, entry lands on the first or last row (or group header) in the viewport.
- **Re-shape while focused.** When a sort or filter reshapes rows while focus is in the body, focus stays at the same row index. Focus elsewhere, including on the header that triggered the sort, stays put.
- **Focus scrolling.** A keyboard-focused row scrolls fully into view. `OnBodyBringIntoViewRequested` drops XAML's 20 DIP vertical padding, so a visible row does not nudge the viewport.
- Gamepad and XY focus navigation are not supported.
## Accessibility
| Peer | Control type | Patterns |
|---|---|---|
| Table | `DataGrid` | `Grid`, `Table`, `ItemContainer`; `Selection` unless `SelectionMode` is `None`; `Scroll` from the body scroller. Bounds exclude cache rows. |
| Row | `DataItem` | `SelectionItem` unless `SelectionMode` is `None`; `ExpandCollapse` for tree rows; `VirtualizedItem` while virtualized. |
| Cell | `DataItem` ("cell") | `GridItem`, `TableItem`; `Value` only when editable with a text editor; `VirtualizedItem`. |
| Column header | `HeaderItem` | `Invoke` (sort) when sortable; `Transform` (resize) when resizable, with `CanResize` false while pinned. |
| Group header | `Group` | `ExpandCollapse` always (empty groups report `LeafNode`), `GridItem`. |
The column-header peer implements `ITransformProvider`, though the IDL lists only `IInvokeProvider`.
- **Visible-column model.** UIA counts only columns with `Visibility.Visible` in `ColumnCount`, `GetItem`, header sets and column indices, unlike WPF, so a collapsed column is never an addressable cell; a visible zero-width column still counts.
- **Header context from one source.** A cell's header comes from `GetColumnHeaderItems` or its name, never both, so it is not announced twice. With headers hidden, the name becomes "{header}, {value}". An empty cell is "blank". Exception: a cell holding a focusable control (e.g. a Delete `Button`) is named after its column header even while headers are shown, since the control names itself.
- **Header names** come from a string `Header` or the realized content, never the gridline, glyph or gripper.
- **Unknown values.** `PositionInSet`, `SizeOfSet` and `Level` report `0` when unknown, and app-set `AutomationProperties` win. The group-header peer computes no `Level`.
- **Stale items.** A `GridItem` whose row, item or column is gone throws `UIA_E_ELEMENTNOTAVAILABLE` rather than reporting a wrong coordinate.
Strings come from the `Microsoft.UI.Xaml.Controls.Tabular/Resources` PRI subtree; a missing resource falls back safely.
## Styling, theming and template contracts
| v1 knob | Effect |
|---|---|
| `RowBackground`, `AlternatingRowBackground` | Opt-in banding; both null means unbanded. |
| `GridLinesVisibility` | Body gridlines. Header lines are always drawn. |
| `Density` | Row minimum height (`TableViewRowMinHeightCompact` / `TableViewRowMinHeight` / `TableViewRowMinHeightComfortable`, 30/40/48) and padding. |
| `ColumnHeaderStyle`, `TableViewColumn.HeaderStyle` | Header `Style`; the column's wins; no `BasedOn` needed. |
| Theme keys | `TableViewCellFontSize` and `TableViewHeaderFontSize` (both 14), `TableViewHeaderFontWeight` (`SemiBold`), `TabularSurface*` brushes. |
Brushes come from `TabularSurfaces_themeresources.xaml` and re-resolve on theme changes. `TableView.xaml` does not redeclare them, which would shadow their High Contrast values.
| Key | Used for | Fluent | High Contrast |
|---|---|---|---|
| `TabularSurfaceGridLineBrush` | Body gridlines | `DividerStrokeColorDefault` | `SystemColorWindowTextColor` |
| `TabularSurfaceHeaderSeparatorBrush` | Header lines | `DividerStrokeColorDefault` | `SystemColorWindowTextColor` |
| `TabularSurfaceOuterBorderBrush` | Control border | `CardStrokeColorDefault` | `SystemColorWindowTextColor` |
A sortable header's `CommonStates` change only the fill, so an app foreground survives hover; in High Contrast, `HighContrastStates` also swap the foreground. Row `CornerRadius` feeds only the focus visual, keeping the row stack rectangular. Cells and the header `PART_Root` are `Grid`s, which clip when rounded, so `AttachFocusCornerRadius` rounds them only while the keyboard focus rect shows.
**Later:** per-element `RowStyle` (on `TableViewRow`) and `CellStyle` (on the `TableViewCell` wrapper), as in WPF, rather than a monolithic `TableStyle`, so standard `Style` machinery applies without a property explosion.
**Template contracts.** MIDL3 does not surface `[TemplatePart]`. "Resolved" parts are looked up in code. "State" parts are targeted by the default visual states, so a re-template that removes one must remove its setters.
| Template | Parts |
|---|---|
| `TableView` | Resolved: `PART_HeaderHost` (`Panel`, required), `PART_RowsRepeater` (`ItemsRepeater`, required, do not bind `ItemsSource`), `PART_HeaderRow` (collapsed when headers hide), `PART_EmptyStatePresenter` (`ContentControl`, optional). |
| `TableViewRow` | Resolved: `PART_CellsHost` (`Panel`, required), `PART_GridLineBorder` and `PART_RowExpanderGutter` (optional). State: `PART_RootBorder`, `PART_CellForegroundPresenter`, `PART_SelectionIndicator`, `PART_RowExpanderIcon`. |
| `TableViewColumnHeader` | Resolved, optional: `PART_ContentPresenter`, `PART_GridLine`, `PART_SortIndicatorHost`, `PART_ResizeGripperHost`. State: `PART_Root`, `PART_ContentPresenter`, `PART_SortIndicatorHost`. |
| `TableViewGroupHeader` | Resolved, optional: `PART_GridLineBorder`. State: `PART_Root`, `PART_ExpanderIcon`, `PART_ContentPresenter`. |
The scrollers are found as the nearest `ScrollViewer` ancestors of the header host and repeater, not by name. Row states are `CommonStates` (including four `Selected*` states), `RowExpansionStates`, `RowExpandabilityStates` and `RowHierarchyStates`, `Row`-prefixed so they cannot collide with an app's `Expanded`/`Collapsed`.
## Lifetime, threading and re-entrancy
- Columns and rows hold weak back-pointers to their `TableView`; cross-object events use `auto_revoke`.
- `Unloaded` clears `PART_RowsRepeater.ItemsSource` so queued repeater work cannot run on a detached subtree; `Loaded` restores it and the selection.
- `ThemeSettings` is released on `Unloaded`, or earlier on `AppWindow.Destroying`, because removing its handler after the window is destroyed fails fast. Hosts with no `AppWindow`, or one on another thread, use `Unloaded`.
- `TableView` and `TableViewSource` are UI-thread-affine. Editing re-entrancy rules are in [Editing](#editing).
## Build, namespace and ABI notes
Code comments link here; keep the reasons intact.
- **`SortDirection` namespace.** `TabularShaping/SortDirection.idl` declares it into `Microsoft.UI.Xaml.Controls.Tabular`. It must not go into `Microsoft.UI.Xaml.Data`, which the Windows App SDK also owns: a C# consumer of both winmds binds that namespace to the SDK's copy and fails with `CS0234`.
- **Tabular metadata IDL.** `Microsoft.UI.Xaml.Controls.Tabular.idl` is a renamed copy of MUXC's metadata IDL that produces `XamlControlsTabularXamlMetaDataProvider` and `TabularControlsResources`, avoiding duplicate runtime classes. It does not redeclare `Microsoft.UI.Xaml.CustomAttributes`, which come from MUXC's winmd (see `Tabular.ProjectImports.targets`), and must stay the first `@(Midl)` entry so its `MUX_*` macros reach every later IDL.
- **Private namespace.** `MU_PRIVATE_CONTROLS_NAMESPACE` equals MUXC's value (`Microsoft.UI.Private.Controls`); a Tabular child namespace breaks cppwinrt, which includes a parent that is never generated. It is safe because this build never references MUXC's private winmd. That namespace, not `[MUX_INTERNAL]`, keeps `ResizeGripper` and its peer out of shipped winmds.
- **Frozen overloads.** `[default_overload]`/`[method_name]` on `TableViewSource.GroupBy` (`GroupBy`, `GroupByWithIdentity`) and `Sort` (`SortByPath`, `Sort`) are frozen ABI; without them MIDL emits `GroupBy2`/`Sort2`.
- **`TableViewSource` is sealed** because `TableView` drives the concrete type, so an override would be bypassed. Unsealing later is compatible; sealing later is not.
- **`TableView` is unsealed** so apps can derive from it, consistent with other MUXC controls such as `ItemsView`, `ItemsRepeater`, `NavigationView` and `TabView`. (`ReferenceTracker` does not require it: the sealed `TableViewColumnHeader` and `TableViewSource` use it too.) `TableViewColumn` has a public constructor so apps can derive columns.
- **`TableViewRowTemplateSelector`** is projected only because `ItemTemplateWrapper` is in another DLL. It is `[MUX_INTERNAL]`, and unsealed with a constructor because MIDL requires that for a member-less class.
# Telemetry and TiP
@@ -691,20 +481,28 @@ Each starts at 1, the first release.
# Appendix
## Non-goals
## API notes not yet in the API spec
`TableView` v1 does not attempt to replace `DataGrid`. The true v1 non-goals are marquee selection, multi-column sort, column virtualization, row headers, and spreadsheet-like interaction. (Selection, single-column sort, filtering, grouping, and hierarchical rows are v1 features, not non-goals, and are delivered. Cell editing is delivered by this PR.)
These contracts are documented only here; they belong in the [API spec](../../api-specs/TableView/TableView-spec.md).
## Out of Scope
- **Sort methods.** `SortByColumn(column, None)` clears, even when `CanSort` is `false`. `ToggleSortDirection` walks the column's `SortCycle`. Both return `false`, changing nothing, when `Sorting` is canceled, an edit will not close, the column cannot sort (`CanSort=False`, or no sort path and no `CustomSortComparer`), rows lack identity, or the source is refused. `CanUserSortColumns=False` gates only header clicks. `TableView.ClearSort()` returns `false` when only an app delegate axis is active; use `TableViewSource.ClearSort()`.
- **Sort events.** `Sorting` is raised first and is cancellable, letting the app own the order and light the chevron with `TableViewSource.Sort(path, …)`. `Sorted` follows. A null `Column` means clear-all. Setting `CanSort=False` on a column sorted by the control clears that sort with both events; a column lit only by `TableViewSource.Sort(...)` keeps the app's sort and raises neither.
- **`SortCycle`** is per column. Its first direction is the opening one; the `*None` variants add a step back to source order.
- **`SortMemberPath`.** When empty, `TableViewTextColumn` uses `Binding.Path.Path` if the binding targets the row item (no `Source`, `RelativeSource` or `ElementName`), through the overridable `GetSortMemberPathCore()`. `CustomSortComparer` wins over the path and must be a pure function.
- **`TableViewSource`.** `From` accepts `IVector<Object>`, `IBindableVector`, `IIterable<Object>` or `IBindableIterable`. Null items, predicate or key selector throw `E_INVALIDARG`; `groupIdentitySelector` may be null. Use `ClearFilter()` to remove a filter. `ParentBy` errors are in the [hierarchy API spec](Hierarchical-Data-Rows-API-Spec.md).
- **Context flyouts.** First match wins. Cell: a `ContextFlyout` in the cell content, `CellContextFlyout`, the row's `ContextFlyout`, `TableView.ContextFlyout`. Header: a `ContextFlyout` in the header content, `HeaderContextFlyout`, `TableView.ContextFlyout`. Opening one focuses its target and does not change selection. When the table resolves the request (keyboard, or pointer on padding), a text control inside the content counts only with a locally set `ContextFlyout` (not a Style setter), and nested `TableView`s and items inside a nested items host are skipped.
- **`TableViewRow.Level`** is 1-based for tree rows and `0` otherwise, matching `AutomationProperties.Level`.
Delivered later in the v1 stack: column reorder · navigation-state persistence · samples · tests · theme-XBF emission.
## Known limitations
Already delivered in the v1 stack: row selection · shaping primitives · filtering · single-column sort · single-level grouping.
- **`*` needs a bounded width.** In a width-to-content host, `*` columns keep their provisional width.
- **Fixed columns wider than the viewport** collapse `*` columns to `MinWidth`, as in WPF.
- **Width-dependent height.** An `Auto` cell that fits its column is not re-measured at the final width.
- **Resize differs from WPF** next to `Pixel`/`Auto` neighbors: WPF assigns the neighbor a width, while `TableView` only redistributes `*` space, so those layouts pin rather than overflow.
- **Grouping** is single-level, with no per-group expand or collapse by key.
Out of v1 / follow-up work (see Non-goals): `Auto` auto-shrink (v1 widths grow monotonically) · trailing frozen columns · multi-column sort · column virtualization · row headers · clipboard · incremental loading · multi-cell row edit transactions · editing a11y announcements.
## Non-goals and deferred work
## Accessibility notes
The UI Automation grid/table provider counts and exposes only **visible** columns: a column with `Visibility.Collapsed` is excluded from the UIA column model, so `ColumnCount`, `GetItem`, the column-header set, and every cell/header column index (`GridItem`/`TableItem` `Column`, `PositionInSet`/`SizeOfSet`) are on a visible-column basis. Consequently, toggling a column's `Visibility` changes the reported UIA column geometry (indices to the right shift). This intentionally diverges from WPF's `DataGridAutomationPeer` (which indexes the full `Columns` collection) so that a collapsed, zero-width column is never surfaced as an addressable off-screen cell. The visual layer still generates a hidden cell per collapsed column (see *Row*), but the automation tree does not expose it.
A cell's column-header context comes from exactly one source: `ITableItemProvider.GetColumnHeaderItems` or the cell name, never both. Screen readers announce the header items when focus enters a new column, so naming a cell "{header}, {value}" while its header is reachable makes the header heard twice. The cell name carries the header only while `HeadersVisibility` hides the column headers, and `GetColumnHeaderItems` is then empty. An empty cell is named "blank" rather than after its header, so it never reads like a cell whose value is the header text. The one exception is a cell holding an interactive control: its name is the column header, because the value is the control's own name and repeating that would be worse.
- **Not yet implemented:** column reorder, trailing frozen columns, multi-select, `RowStyle`/`CellStyle`, programmatic `BeginEdit`, row-scoped editing.
- **Deferred:** `Auto` shrink, multi-column sort, clipboard, incremental loading, editing announcements.
- **Non-goals:** column virtualization, row headers, marquee selection, spreadsheet-style interaction.