diff --git a/Samples/TableViewSampleApp/MainWindow.xaml b/Samples/TableViewSampleApp/MainWindow.xaml
index ac317cff08..3e37f22baf 100644
--- a/Samples/TableViewSampleApp/MainWindow.xaml
+++ b/Samples/TableViewSampleApp/MainWindow.xaml
@@ -18,6 +18,7 @@
+
diff --git a/Samples/TableViewSampleApp/MainWindow.xaml.cs b/Samples/TableViewSampleApp/MainWindow.xaml.cs
index 0c96578193..1a0a3b755a 100644
--- a/Samples/TableViewSampleApp/MainWindow.xaml.cs
+++ b/Samples/TableViewSampleApp/MainWindow.xaml.cs
@@ -32,6 +32,7 @@ private void Nav_SelectionChanged(NavigationView sender, NavigationViewSelection
"mixed" => typeof(MixedColumnsPage),
"interactive" => typeof(InteractiveCellsPage),
"selection" => typeof(SelectionPage),
+ "tooltips" => typeof(ToolTipsPage),
_ => typeof(PlaygroundPage),
};
diff --git a/Samples/TableViewSampleApp/ToolTipsPage.xaml b/Samples/TableViewSampleApp/ToolTipsPage.xaml
new file mode 100644
index 0000000000..aff4576589
--- /dev/null
+++ b/Samples/TableViewSampleApp/ToolTipsPage.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Samples/TableViewSampleApp/ToolTipsPage.xaml.cs b/Samples/TableViewSampleApp/ToolTipsPage.xaml.cs
new file mode 100644
index 0000000000..ebcf0a417d
--- /dev/null
+++ b/Samples/TableViewSampleApp/ToolTipsPage.xaml.cs
@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Automation.Peers;
+using Microsoft.UI.Xaml.Media;
+using Windows.Foundation;
+// Disambiguate the real split-binary types from the stale mock projection
+// (Microsoft.UI.Xaml.Controls.TableView*) that the mock Microsoft.WinUI.dll still carries.
+using TableView = Microsoft.UI.Xaml.Controls.Tabular.TableView;
+using TableViewColumn = Microsoft.UI.Xaml.Controls.Tabular.TableViewColumn;
+using TableViewTemplateColumn = Microsoft.UI.Xaml.Controls.Tabular.TableViewTemplateColumn;
+using TableViewCellToolTipRequestedEventArgs = Microsoft.UI.Xaml.Controls.Tabular.TableViewCellToolTipRequestedEventArgs;
+
+namespace TableViewSampleApp;
+
+// Exercises the opt-in per-cell tooltip feature end to end: string content, non-string content with
+// a UIA override, cells that deliberately get no tooltip, a cell whose own template owns a tooltip,
+// late attach/detach with InvalidateCellToolTips, re-entrant invalidation from inside the handler,
+// and a throwing handler (which must be contained, not fatal).
+public sealed partial class ToolTipsPage : Page
+{
+ private readonly TableViewColumn _nameColumn;
+ private readonly TableViewColumn _bioColumn;
+ private readonly TableViewColumn _scoreColumn;
+
+ private TypedEventHandler? _handler;
+ private bool _attached;
+ private bool _enabled = true;
+ private bool _throwInHandler;
+ private int _peerCells;
+ private bool _reentrant;
+ private int _requestCount;
+ private int _mutations;
+ private bool _inHandler;
+ private readonly HashSet _itemsSeen = new();
+
+ public ToolTipsPage()
+ {
+ this.InitializeComponent();
+
+ _nameColumn = SampleColumns.Text("Name", nameof(Item.Name), SampleColumns.Pixels(140));
+ _bioColumn = SampleColumns.Text("Bio", nameof(Item.Bio), SampleColumns.Pixels(180));
+ _scoreColumn = SampleColumns.Text("Score", nameof(Item.Score), SampleColumns.Pixels(70));
+
+ Table.Columns.Add(_nameColumn);
+ Table.Columns.Add(_bioColumn);
+ Table.Columns.Add(_scoreColumn);
+ Table.Columns.Add(new TableViewTemplateColumn
+ {
+ Header = "Notes (app tooltip)",
+ CellTemplate = (DataTemplate)Resources["AppToolTipCell"],
+ Width = SampleColumns.Pixels(220),
+ });
+
+ Table.ItemsSource = Data.Make();
+
+ _handler = OnCellToolTipRequested;
+ Table.CellToolTipRequested += _handler;
+ _attached = true;
+
+ UpdateStatus();
+ }
+
+ private void OnCellToolTipRequested(TableView sender, TableViewCellToolTipRequestedEventArgs args)
+ {
+ _requestCount++;
+
+ if (_throwInHandler)
+ {
+ throw new InvalidOperationException("Deliberate handler fault - the control must contain this.");
+ }
+
+ // Re-entrancy: the API documents that this is safe to call from a handler. If the coalescing
+ // guard regressed, this recurses without bound instead of queueing one more pass.
+ if (_reentrant && !_inHandler)
+ {
+ _inHandler = true;
+ try
+ {
+ sender.InvalidateCellToolTips();
+ }
+ finally
+ {
+ _inHandler = false;
+ }
+ }
+
+ if (!_enabled || args.Item is not Item item)
+ {
+ return;
+ }
+
+ // Recycling proof: a recycled row must re-raise for its NEW item, so this set grows past the
+ // ~28 rows realized on the first screen once the table is scrolled.
+ _itemsSeen.Add(item.Name + "|" + item.Score);
+
+ if (args.Column == _bioColumn)
+ {
+ // Plain string content: the control publishes it as UIA HelpText too.
+ args.Content = item.Bio + (_mutations > 0 ? $" [rev {_mutations}]" : string.Empty);
+ }
+ else if (args.Column == _nameColumn)
+ {
+ // Non-string content: UIA cannot read it, so AutomationHelpText supplies the spoken text.
+ var panel = new StackPanel { Spacing = 4 };
+ panel.Children.Add(new TextBlock { Text = item.Name, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
+ panel.Children.Add(new TextBlock { Text = $"{item.Role} - {item.City}", Opacity = 0.75 });
+ panel.Children.Add(new Border
+ {
+ Height = 4,
+ Width = 120,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Background = new SolidColorBrush(Microsoft.UI.Colors.SteelBlue),
+ });
+
+ args.Content = panel;
+ args.AutomationHelpText = $"{item.Name}, {item.Role}, {item.City}";
+ }
+ // Score: deliberately left alone - no tooltip should ever appear over that column.
+ }
+
+ private void OnToggleEnabled(object sender, RoutedEventArgs e)
+ {
+ _enabled = !_enabled;
+ EnableToolTips.Content = _enabled ? "Tooltips: ON" : "Tooltips: OFF";
+ Table.InvalidateCellToolTips();
+ UpdateStatus();
+ }
+
+ private void OnToggleThrow(object sender, RoutedEventArgs e)
+ {
+ _throwInHandler = !_throwInHandler;
+ ThrowInHandler.Content = _throwInHandler ? "Throwing handler: ON" : "Throwing handler: OFF";
+ Table.InvalidateCellToolTips();
+ UpdateStatus();
+ }
+
+ private void OnToggleReentrant(object sender, RoutedEventArgs e)
+ {
+ _reentrant = !_reentrant;
+ ReentrantInvalidate.Content = _reentrant ? "Reentrant invalidate: ON" : "Reentrant invalidate: OFF";
+ Table.InvalidateCellToolTips();
+ UpdateStatus();
+ }
+
+ private void OnAttachLate(object sender, RoutedEventArgs e)
+ {
+ if (_attached)
+ {
+ Table.CellToolTipRequested -= _handler;
+ _attached = false;
+ AttachLate.Content = "Attach handler";
+ }
+ else
+ {
+ _handler ??= OnCellToolTipRequested;
+ Table.CellToolTipRequested += _handler;
+ _attached = true;
+ AttachLate.Content = "Detach handler";
+ }
+
+ // Rows are already realized, so the pass only reruns because of this call.
+ Table.InvalidateCellToolTips();
+ UpdateStatus();
+ }
+
+ private void OnMutateData(object sender, RoutedEventArgs e)
+ {
+ _mutations++;
+ Table.InvalidateCellToolTips();
+ UpdateStatus();
+ }
+
+ private void UpdateStatus()
+ {
+ Status.Text = $"handler {(_attached ? "attached" : "detached")} - requests raised: {_requestCount}"
+ + $" - data revision: {_mutations} - distinct items seen: {_itemsSeen.Count}";
+ }
+
+ private static IEnumerable Descendants(DependencyObject root)
+ {
+ int count = VisualTreeHelper.GetChildrenCount(root);
+ for (int i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(root, i);
+ yield return child;
+ foreach (var d in Descendants(child))
+ {
+ yield return d;
+ }
+ }
+ }
+
+ private void OnScroll(object sender, RoutedEventArgs e)
+ {
+ // Scrolling is what recycles rows - the path rounds 1-3 found the UAF and the stale-content
+ // defects on. Nothing else in this page exercises it.
+ // The template has several ScrollViewers (the header band scrolls too); pick the one that
+ // can actually scroll vertically, or this silently tests nothing.
+ var scrollers = Descendants(Table).OfType().ToList();
+ var scroller = scrollers.OrderByDescending(s => s.ScrollableHeight).FirstOrDefault();
+ if (scroller is null || scroller.ScrollableHeight <= 0)
+ {
+ Status.Text = $"SCROLL: no vertically scrollable ScrollViewer (found {scrollers.Count})";
+ return;
+ }
+
+ var before = scroller.VerticalOffset;
+ _ = scroller.ChangeView(null, before + 900, null, true);
+
+ // Report the offset so a scroll that silently does nothing cannot look like a passing test.
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ Status.Text = $"SCROLL: {before:F0} -> {scroller.VerticalOffset:F0}"
+ + $" (max {scroller.ScrollableHeight:F0}) - requests {_requestCount}"
+ + $" - distinct items {_itemsSeen.Count}";
+ });
+ }
+
+ // Reads the live cell wrappers and reports, per column, who owns each cell's tooltip. This is
+ // the only way to check the ownership rules without UIA (whose tree walks are unreliable here).
+ private void OnCheckOwnership(object sender, RoutedEventArgs e)
+ {
+ var perColumn = new Dictionary();
+
+ foreach (var border in Descendants(Table).OfType())
+ {
+ if (border.Tag is not TableViewColumn column)
+ {
+ continue;
+ }
+
+ var header = column.Header?.ToString() ?? "?";
+ perColumn.TryGetValue(header, out var acc);
+
+ acc.cells++;
+
+ if (ToolTipService.GetToolTip(border) is not null)
+ {
+ acc.controlTips++;
+ }
+
+ var help = AutomationProperties.GetHelpText(border);
+ if (!string.IsNullOrEmpty(help))
+ {
+ acc.helpTexts++;
+ }
+
+ // An app tooltip declared inside the cell template lives on the content, not the wrapper.
+ foreach (var inner in Descendants(border))
+ {
+ if (inner is FrameworkElement fe && ToolTipService.GetToolTip(fe) is not null)
+ {
+ acc.appTipsInContent++;
+ break;
+ }
+ }
+
+ perColumn[header] = acc;
+ }
+
+ var sb = new StringBuilder();
+ foreach (var kv in perColumn.OrderBy(k => k.Key))
+ {
+ sb.Append($"[{kv.Key}] cells={kv.Value.cells} tip={kv.Value.controlTips} ")
+ .Append($"appTip={kv.Value.appTipsInContent} help={kv.Value.helpTexts} ");
+ }
+
+ var dup = CountDuplicateSpokenHelpText();
+ sb.Append($"UIA-cells={_peerCells} UIA-DUPhelp={dup}");
+
+ Status.Text = sb.Length > 0 ? sb.ToString() : "no cells found";
+ }
+
+ // Counts cells whose spoken HelpText merely repeats the value already in their spoken Name, i.e.
+ // the ones Narrator would read twice. Walks the automation peers rather than the attached
+ // property: the control publishes HelpText eagerly and suppresses the redundant case at query
+ // time, so only the peer reports what is actually announced.
+ private int CountDuplicateSpokenHelpText()
+ {
+ var duplicates = 0;
+ _peerCells = 0;
+
+ void Visit(AutomationPeer peer)
+ {
+ if (peer.GetAutomationControlType() == AutomationControlType.DataItem)
+ {
+ _peerCells++;
+ var help = peer.GetHelpText();
+ var name = peer.GetName();
+ if (!string.IsNullOrEmpty(help) && !string.IsNullOrEmpty(name) && name.EndsWith(help, StringComparison.Ordinal))
+ {
+ duplicates++;
+ }
+ }
+
+ foreach (var child in peer.GetChildren() ?? new List())
+ {
+ Visit(child);
+ }
+ }
+
+ var root = FrameworkElementAutomationPeer.CreatePeerForElement(Table);
+ if (root is not null)
+ {
+ Visit(root);
+ }
+
+ return duplicates;
+ }
+}
diff --git a/controls/dev/Generated/TableView.properties.cpp b/controls/dev/Generated/TableView.properties.cpp
index 8e33803995..7809ff99d0 100644
--- a/controls/dev/Generated/TableView.properties.cpp
+++ b/controls/dev/Generated/TableView.properties.cpp
@@ -32,6 +32,7 @@ GlobalDependencyProperty TableViewProperties::s_SelectionModeProperty{ nullptr }
TableViewProperties::TableViewProperties()
: m_beginningEditEventSource{static_cast(this)}
, m_cellEditEndingEventSource{static_cast(this)}
+ , m_cellToolTipRequestedEventSource{static_cast(this)}
, m_selectionChangedEventSource{static_cast(this)}
, m_sortedEventSource{static_cast(this)}
, m_sortingEventSource{static_cast(this)}
@@ -538,6 +539,16 @@ void TableViewProperties::CellEditEnding(winrt::event_token const& token)
m_cellEditEndingEventSource.remove(token);
}
+winrt::event_token TableViewProperties::CellToolTipRequested(winrt::TypedEventHandler const& value)
+{
+ return m_cellToolTipRequestedEventSource.add(value);
+}
+
+void TableViewProperties::CellToolTipRequested(winrt::event_token const& token)
+{
+ m_cellToolTipRequestedEventSource.remove(token);
+}
+
winrt::event_token TableViewProperties::SelectionChanged(winrt::TypedEventHandler const& value)
{
return m_selectionChangedEventSource.add(value);
diff --git a/controls/dev/Generated/TableView.properties.h b/controls/dev/Generated/TableView.properties.h
index 7057131995..8f02904488 100644
--- a/controls/dev/Generated/TableView.properties.h
+++ b/controls/dev/Generated/TableView.properties.h
@@ -90,6 +90,8 @@ class TableViewProperties
void BeginningEdit(winrt::event_token const& token);
winrt::event_token CellEditEnding(winrt::TypedEventHandler const& value);
void CellEditEnding(winrt::event_token const& token);
+ winrt::event_token CellToolTipRequested(winrt::TypedEventHandler const& value);
+ void CellToolTipRequested(winrt::event_token const& token);
winrt::event_token SelectionChanged(winrt::TypedEventHandler const& value);
void SelectionChanged(winrt::event_token const& token);
winrt::event_token Sorted(winrt::TypedEventHandler const& value);
@@ -99,6 +101,7 @@ class TableViewProperties
event_source> m_beginningEditEventSource;
event_source> m_cellEditEndingEventSource;
+ event_source> m_cellToolTipRequestedEventSource;
event_source> m_selectionChangedEventSource;
event_source> m_sortedEventSource;
event_source> m_sortingEventSource;
diff --git a/controls/dev/TableView/TableView.cpp b/controls/dev/TableView/TableView.cpp
index cfdfa2f181..59c4fce160 100644
--- a/controls/dev/TableView/TableView.cpp
+++ b/controls/dev/TableView/TableView.cpp
@@ -8,6 +8,7 @@
#include "TableViewRow.h"
#include "TableViewCellsPanel.h"
#include "TableViewAutomationPeer.h"
+#include "TableViewCellToolTipRequestedEventArgs.h"
#include "TableViewSource.h"
#include "SortIndicator.h"
#include "RuntimeProfiler.h"
@@ -32,6 +33,10 @@ static constexpr std::wstring_view s_SortIndicatorName{ L"TableViewSortIndicator
namespace
{
+ // Consecutive follow-up tooltip passes a handler may request by calling InvalidateCellToolTips
+ // from inside CellToolTipRequested. Matches the row's rebuild drain cap.
+ constexpr int c_maxCellToolTipRefreshPasses = 3;
+
// cppwinrt's == compares raw ABI pointers, which can differ for the same object across a QI,
// so fall back to canonical IUnknown identity.
bool IsSameObject(winrt::IInspectable const& left, winrt::IInspectable const& right)
@@ -1298,6 +1303,120 @@ void TableView::OnRowElementPrepared(
}
+winrt::com_ptr TableView::RaiseCellToolTipRequested(
+ const winrt::TableViewColumn& column,
+ const winrt::IInspectable& item)
+{
+ // Contained by the callers, which run from framework callbacks.
+ auto args = winrt::make_self(item, column);
+ m_cellToolTipRequestedEventSource(*this, *args);
+ return args;
+}
+
+void TableView::InvalidateCellToolTips()
+{
+ // Deferred because this is public: raising synchronously from an app handler would re-enter the
+ // pass and walk live children mid-mutation. Matches InvalidateItemsInfo.
+ if (m_cellToolTipRefreshQueued)
+ {
+ m_cellToolTipRefreshDirty = true;
+ return;
+ }
+
+ auto dispatcher = DispatcherQueue();
+ if (!dispatcher)
+ {
+ RefreshCellToolTipsSynchronously();
+ return;
+ }
+
+ m_cellToolTipRefreshQueued = true;
+ auto weakThis = get_weak();
+ if (!dispatcher.TryEnqueue([weakThis]()
+ {
+ if (auto strongThis = weakThis.get())
+ {
+ // Held across the pass so a handler calling back records a follow-up instead of
+ // queueing a callback every tick, and cleared on the throw path so a failed pass
+ // cannot disable every future invalidate.
+ strongThis->m_cellToolTipRefreshDirty = false;
+ try
+ {
+ strongThis->RefreshCellToolTipsOnRealizedRows();
+ }
+ catch (...)
+ {
+ TVDiag::LogRetailF(L"[TableView] Cell-tooltip re-resolve failed (HRESULT 0x%08X).",
+ static_cast(winrt::to_hresult()));
+ }
+ strongThis->m_cellToolTipRefreshQueued = false;
+
+ if (strongThis->m_cellToolTipRefreshDirty)
+ {
+ strongThis->m_cellToolTipRefreshDirty = false;
+
+ // A handler invalidating on every raise would re-arm this forever, so it is
+ // bounded like the row's rebuild drain.
+ if (++strongThis->m_cellToolTipRefreshPasses < c_maxCellToolTipRefreshPasses)
+ {
+ strongThis->InvalidateCellToolTips();
+ return;
+ }
+
+ TVDiag::LogRetailF(L"[TableView] Dropping a cell-tooltip re-resolve after %d passes; "
+ L"a CellToolTipRequested handler is calling InvalidateCellToolTips unconditionally.",
+ c_maxCellToolTipRefreshPasses);
+ }
+
+ strongThis->m_cellToolTipRefreshPasses = 0;
+ }
+ }))
+ {
+ m_cellToolTipRefreshQueued = false;
+ RefreshCellToolTipsSynchronously();
+ }
+}
+
+// Holds the coalescing flag so a handler calling InvalidateCellToolTips records a follow-up instead
+// of re-entering synchronously and recursing without bound. Terminal: there is no dispatcher to
+// replay the follow-up on, so it is dropped with a diagnostic rather than silently.
+void TableView::RefreshCellToolTipsSynchronously()
+{
+ m_cellToolTipRefreshQueued = true;
+ auto queuedGuard = wil::scope_exit([this]()
+ {
+ m_cellToolTipRefreshQueued = false;
+ m_cellToolTipRefreshPasses = 0;
+ });
+
+ RefreshCellToolTipsOnRealizedRows();
+
+ if (m_cellToolTipRefreshDirty)
+ {
+ m_cellToolTipRefreshDirty = false;
+ TVDiag::LogRetailF(L"[TableView] Dropping a cell-tooltip re-resolve requested during a "
+ L"synchronous fallback pass; there is no dispatcher to replay it on.");
+ }
+}
+
+void TableView::RefreshCellToolTipsOnRealizedRows()
+{
+ ForEachRealizedRow([](winrt::TableViewRow const& row)
+ {
+ try
+ {
+ winrt::get_self(row)->RefreshCellToolTips();
+ }
+ catch (...)
+ {
+ // Contained per row: an exception escaping a dispatcher callback fail-fasts, and one
+ // bad row must not abort the rest.
+ TVDiag::LogRetailF(L"[TableView] A row's cell tooltip pass failed (HRESULT 0x%08X). Continuing.",
+ static_cast(winrt::to_hresult()));
+ }
+ });
+}
+
void TableView::OnRowElementClearing(
const winrt::ItemsRepeater& /*sender*/,
const winrt::ItemsRepeaterElementClearingEventArgs& args)
@@ -1330,7 +1449,10 @@ void TableView::OnRowElementClearing(
}
}
- winrt::get_self(row)->SetOwningTableViewInternal(nullptr);
+ auto const rowImpl = winrt::get_self(row);
+ // Release app-supplied tooltip content rather than pinning it in the recycle pool.
+ rowImpl->ReleaseCellToolTips();
+ rowImpl->SetOwningTableViewInternal(nullptr);
InvalidateMeasure();
}
diff --git a/controls/dev/TableView/TableView.h b/controls/dev/TableView/TableView.h
index 677fa73ecd..1c7c5c25fe 100644
--- a/controls/dev/TableView/TableView.h
+++ b/controls/dev/TableView/TableView.h
@@ -86,6 +86,8 @@ struct TableViewResourceCache
double lastFrozenColumnsHorizontalOffset{ 0.0 };
};
+class TableViewCellToolTipRequestedEventArgs;
+
namespace TabularShapingHelpers { class CustomSortRankAdapter; }
// The control's half of the TableViewSource sort axis. The projection is addressed by an opaque
@@ -196,6 +198,18 @@ class TableView :
// Pin rebuilt rows immediately when leading-frozen columns are active.
void PinFrozenColumnsForRow(const winrt::TableViewRow& row);
+ // Internal — true while at least one CellToolTipRequested handler is attached, so the cell
+ // realization path can skip all per-cell tooltip work when nobody is listening.
+ bool HasCellToolTipHandler() const { return static_cast(m_cellToolTipRequestedEventSource); }
+
+ // Internal — raises CellToolTipRequested for one cell and returns the args the handler filled in.
+ winrt::com_ptr RaiseCellToolTipRequested(
+ const winrt::TableViewColumn& column,
+ const winrt::IInspectable& item);
+
+ // Queues a coalesced tooltip re-resolve for every realized cell (public, from the IDL).
+ void InvalidateCellToolTips();
+
// Requested by a cell panel (header/row) during measure when a realized cell's own measured width
// changed (grow or shrink). Invalidates our measure synchronously so ResolveColumnWidths re-runs in
// the same layout tick (no deferral -> no one-frame lag). Bridges the body ScrollViewer, which
@@ -712,6 +726,15 @@ class TableView :
// Set while a coalesced RebuildHeaders is pending on the dispatcher; collapses a burst of column
// changes into one rebuild. UI-thread only (all column callbacks arrive on the UI thread).
bool m_rebuildHeadersQueued{ false };
+ void RefreshCellToolTipsOnRealizedRows();
+ void RefreshCellToolTipsSynchronously();
+ // A coalesced cell-tooltip re-resolve is pending on the dispatcher (InvalidateCellToolTips).
+ bool m_cellToolTipRefreshQueued{ false };
+ // An invalidate arrived while a pass was queued or running; coalesced into one follow-up.
+ bool m_cellToolTipRefreshDirty{ false };
+ // Consecutive handler-requested follow-up passes, capped so an unconditionally-invalidating
+ // handler is dropped rather than pegging the UI thread.
+ int m_cellToolTipRefreshPasses{ 0 };
// Per-instance resource cache; replaces the former process-global map keyed by `this`.
TableViewResourceCache m_resourceCache{};
diff --git a/controls/dev/TableView/TableView.idl b/controls/dev/TableView/TableView.idl
index c103e6e34f..171dff89c5 100644
--- a/controls/dev/TableView/TableView.idl
+++ b/controls/dev/TableView/TableView.idl
@@ -279,6 +279,29 @@ runtimeclass TableViewCellEditEndingEventArgs
Boolean Cancel;
};
+// Args for CellToolTipRequested.
+[MUX_PREVIEW]
+[webhosthidden]
+runtimeclass TableViewCellToolTipRequestedEventArgs
+{
+ // The data item of the row the cell belongs to.
+ Object Item { get; };
+
+ // The column of the cell. Always non-null.
+ MU_XC_NAMESPACE.TableViewColumn Column { get; };
+
+ // The tooltip content: a string, or any content a ToolTip can host. Null or an empty string
+ // means no tooltip. A ToolTip is not valid content -- the control owns the ToolTip and its
+ // placement. A UIElement is parented by that cell's ToolTip, so return a fresh element per
+ // raise: the same instance handed to a second cell cannot be parented twice and is dropped.
+ Object Content { get; set; };
+
+ // Accessible text, published as the cell's UIA HelpText unless the cell already reports that
+ // same text, so Narrator does not read it twice. Set it when Content is not a string; it
+ // overrides string Content, and is ignored when Content is null.
+ String AutomationHelpText { get; set; };
+};
+
[MUX_PREVIEW]
[webhosthidden]
unsealed runtimeclass TableViewTextColumn : TableViewColumn
@@ -575,6 +598,23 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
// Raised AFTER a sort-state change has been applied. Not cancellable.
event Windows.Foundation.TypedEventHandler Sorted;
+ // ----- Tooltips -----
+ // Raised as each cell's tooltip is resolved: on realization, on cell rebuild, when a cell edit
+ // closes, and on InvalidateCellToolTips. May be raised repeatedly for the same cell, so handlers
+ // should be cheap and side-effect free. Opt-in -- not raised when no handler is attached. A
+ // tooltip the app sets inside a cell's own content template opens over that content; the
+ // control's tooltip covers the rest of the cell. Removing the last handler stops new tooltips
+ // from being resolved; call InvalidateCellToolTips to retract the ones already showing.
+ event Windows.Foundation.TypedEventHandler CellToolTipRequested;
+
+ // Queues a coalesced re-resolve of every realized cell; it normally does not run before this
+ // call returns, falling back to running synchronously only when there is no dispatcher. Use it
+ // after attaching a handler to a TableView whose rows are already realized, or when the data
+ // behind the tooltips changes. Calling it from a CellToolTipRequested handler is
+ // re-entrancy-safe, but call it only when the state behind the tooltips has actually changed: a
+ // handler that invalidates on every raise is dropped after a few passes.
+ void InvalidateCellToolTips();
+
static Microsoft.UI.Xaml.DependencyProperty ItemsSourceProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty ColumnsProperty { get; };
static Microsoft.UI.Xaml.DependencyProperty HeadersVisibilityProperty { get; };
diff --git a/controls/dev/TableView/TableView.vcxitems b/controls/dev/TableView/TableView.vcxitems
index f3129e65a7..0e748f90b4 100644
--- a/controls/dev/TableView/TableView.vcxitems
+++ b/controls/dev/TableView/TableView.vcxitems
@@ -42,6 +42,8 @@
+
+
diff --git a/controls/dev/TableView/TableViewAutomationHelpers.h b/controls/dev/TableView/TableViewAutomationHelpers.h
index 617b1803ef..f369de765d 100644
--- a/controls/dev/TableView/TableViewAutomationHelpers.h
+++ b/controls/dev/TableView/TableViewAutomationHelpers.h
@@ -50,3 +50,37 @@ inline std::optional TryGetColumnHeaderString(winrt::TableViewCo
}
return std::nullopt;
}
+
+// The text a realized cell reports to UIA: the generated TextBlock for a text column, otherwise the
+// standard UIA name of the column-generated content. Shared by TableViewCellAutomationPeer's name
+// and help-text composition, so the two cannot drift. Only called from the peer, so the automation
+// peer it may allocate is created when UIA is actually asking.
+inline winrt::hstring GetCellValueTextFromWrapper(winrt::FrameworkElement const& cellWrapper)
+{
+ if (!cellWrapper)
+ {
+ return {};
+ }
+
+ winrt::FrameworkElement content{ nullptr };
+ if (auto const border = cellWrapper.try_as())
+ {
+ content = border.Child().try_as();
+ }
+ if (!content)
+ {
+ content = cellWrapper;
+ }
+
+ if (auto const textBlock = content.try_as())
+ {
+ return textBlock.Text();
+ }
+
+ if (auto const peer = winrt::FrameworkElementAutomationPeer::CreatePeerForElement(content))
+ {
+ return peer.GetName();
+ }
+
+ return {};
+}
diff --git a/controls/dev/TableView/TableViewCellAutomationPeer.cpp b/controls/dev/TableView/TableViewCellAutomationPeer.cpp
index da9ccbf6bb..132b7bea9c 100644
--- a/controls/dev/TableView/TableViewCellAutomationPeer.cpp
+++ b/controls/dev/TableView/TableViewCellAutomationPeer.cpp
@@ -97,36 +97,22 @@ winrt::hstring TableViewCellAutomationPeer::GetColumnHeaderText()
winrt::hstring TableViewCellAutomationPeer::GetCellValueText()
{
- auto const cell = Owner().try_as();
- if (!cell)
- {
- return {};
- }
-
- // The cell wrapper's child is the column-generated content.
- winrt::FrameworkElement content{ nullptr };
- if (auto const border = cell.try_as())
- {
- content = border.Child().try_as();
- }
- if (!content)
- {
- content = cell;
- }
+ return GetCellValueTextFromWrapper(Owner().try_as());
+}
- // Common text-column case: read the generated TextBlock.
- if (auto const textBlock = content.try_as())
- {
- return textBlock.Text();
- }
+hstring TableViewCellAutomationPeer::GetHelpTextCore()
+{
+ auto const helpText = __super::GetHelpTextCore();
- // Template content uses the standard UIA name computation.
- if (auto const peer = winrt::FrameworkElementAutomationPeer::CreatePeerForElement(content))
+ // Resolved here rather than when the tooltip is attached: at that point the cell's content is
+ // freshly generated and its binding has not produced a value yet, so the texts always compare
+ // unequal and the value would be announced twice.
+ if (!helpText.empty() && helpText == GetCellValueText())
{
- return peer.GetName();
+ return {};
}
- return {};
+ return helpText;
}
int32_t TableViewCellAutomationPeer::GetRowIndex()
diff --git a/controls/dev/TableView/TableViewCellAutomationPeer.h b/controls/dev/TableView/TableViewCellAutomationPeer.h
index ed84c9397a..d8503e7930 100644
--- a/controls/dev/TableView/TableViewCellAutomationPeer.h
+++ b/controls/dev/TableView/TableViewCellAutomationPeer.h
@@ -22,6 +22,7 @@ class TableViewCellAutomationPeer :
winrt::IInspectable GetPatternCore(winrt::PatternInterface const& patternInterface);
hstring GetClassNameCore();
hstring GetNameCore();
+ hstring GetHelpTextCore();
winrt::AutomationControlType GetAutomationControlTypeCore();
// IGridItemProvider — per-cell coordinates in the owning TableView.
diff --git a/controls/dev/TableView/TableViewCellToolTipRequestedEventArgs.h b/controls/dev/TableView/TableViewCellToolTipRequestedEventArgs.h
new file mode 100644
index 0000000000..8ad58c4293
--- /dev/null
+++ b/controls/dev/TableView/TableViewCellToolTipRequestedEventArgs.h
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+#pragma once
+
+#include "TableViewCellToolTipRequestedEventArgs.g.h"
+
+class TableViewCellToolTipRequestedEventArgs :
+ public winrt::implementation::TableViewCellToolTipRequestedEventArgsT
+{
+public:
+ TableViewCellToolTipRequestedEventArgs(
+ winrt::IInspectable const& item,
+ winrt::TableViewColumn const& column)
+ : m_column(column)
+ , m_item(item)
+ {
+ }
+
+ winrt::IInspectable Item() { return m_item; }
+ winrt::TableViewColumn Column() { return m_column; }
+ winrt::IInspectable Content() { return m_content; }
+ void Content(winrt::IInspectable const& value) { m_content = value; }
+ winrt::hstring AutomationHelpText() { return m_automationHelpText; }
+ void AutomationHelpText(winrt::hstring const& value) { m_automationHelpText = value; }
+
+private:
+ winrt::TableViewColumn m_column{ nullptr };
+ winrt::IInspectable m_item{ nullptr };
+ winrt::IInspectable m_content{ nullptr };
+ winrt::hstring m_automationHelpText{};
+};
diff --git a/controls/dev/TableView/TableViewRow.cpp b/controls/dev/TableView/TableViewRow.cpp
index 1778b0f363..9a7d01d925 100644
--- a/controls/dev/TableView/TableViewRow.cpp
+++ b/controls/dev/TableView/TableViewRow.cpp
@@ -4,6 +4,9 @@
#include "pch.h"
#include "common.h"
#include "TableViewRow.h"
+#include "TableViewAutomationHelpers.h"
+#include "TableViewToolTipHelpers.h"
+#include "TableViewCellToolTipRequestedEventArgs.h"
#include "TableView.h"
#include "TableViewColumn.h"
#include "TableViewCellsPanel.h"
@@ -14,6 +17,10 @@ static constexpr std::wstring_view s_CellsHostPartName{ L"PART_CellsHost"sv };
namespace
{
+ // One pass, plus replays for app code that mutates the row from a CellToolTipRequested handler.
+ // Shared by both drain loops so a handler cannot get more replays by entering the other one.
+ constexpr int c_maxCellDrainPasses = 3;
+
constexpr winrt::Thickness s_verticalThickness{ 0, 0, 1, 0 };
constexpr winrt::Thickness s_zeroThickness{ 0, 0, 0, 0 };
@@ -283,12 +290,9 @@ void TableViewRow::OnDataContextChanged(
{
// On recycle ItemsRepeater updates the row's DataContext; cells pick up the new item reactively
// via inheritance (they are not restamped). RebuildCells is still called so index-dependent visuals
- // (alternating-row banding, frozen pinning) refresh for the new position. Guard against re-entry
- // from child DataContext propagation.
- if (m_isRebuildingCells)
- {
- return;
- }
+ // (alternating-row banding, frozen pinning) refresh for the new position. RebuildCells owns the
+ // re-entry policy for both rebuilds and the tooltip pass; a bare guard here would drop the
+ // request instead of recording it.
RebuildCells();
}
@@ -545,6 +549,44 @@ void TableViewRow::InvalidateCells()
}
void TableViewRow::RebuildCells()
+{
+ // A handler can drop the last reference to this row.
+ auto strongThis = get_strong();
+
+ if (m_isRebuildingCells || m_isRefreshingCellToolTips)
+ {
+ RebuildCellsCore();
+ return;
+ }
+
+ // Replays a rebuild an app handler queued. Calls the Core primitives, never the public
+ // RefreshCellToolTips, which drains too and would nest a fresh counter instead of tripping
+ // the cap. Beyond it the app is livelocking the UI thread, so the request is dropped.
+ for (int pass = 0; pass < c_maxCellDrainPasses; ++pass)
+ {
+ m_rebuildCellsPending = false;
+ m_refreshCellToolTipsPending = false;
+ RebuildCellsCore();
+ RefreshCellToolTipsGuarded();
+
+ // A refresh requested from inside the pass (an edit that closed under a handler) needs
+ // another pass: its cell was excluded from the targets this one took.
+ if (!m_rebuildCellsPending && !m_refreshCellToolTipsPending)
+ {
+ break;
+ }
+ }
+
+ if (m_rebuildCellsPending || m_refreshCellToolTipsPending)
+ {
+ TVDiag::LogRetailF(L"[TableViewRow] Dropping pending cell work after %d drain passes.",
+ c_maxCellDrainPasses);
+ m_rebuildCellsPending = false;
+ m_refreshCellToolTipsPending = false;
+ }
+}
+
+void TableViewRow::RebuildCellsCore()
{
auto host = m_cellsHost.get();
if (!host)
@@ -552,13 +594,16 @@ void TableViewRow::RebuildCells()
return;
}
- // Re-entry guard. See OnDataContextChanged.
- if (m_isRebuildingCells)
+ // Re-entry guard. See OnDataContextChanged. A request that arrives while we are rebuilding, or
+ // while the tooltip pass is calling into app code, is recorded rather than dropped: rebuilding
+ // mid-pass would detach the very wrappers that pass is still applying tooltips to.
+ if (m_isRebuildingCells || m_isRefreshingCellToolTips)
{
+ m_rebuildCellsPending = true;
return;
}
m_isRebuildingCells = true;
- // Synchronous RAII guard: captures this only until RebuildCells returns.
+ // Synchronous RAII guard: captures this only until RebuildCellsCore returns.
auto rebuildGuard = wil::scope_exit([this]() { m_isRebuildingCells = false; });
auto owner = GetOwningTableView();
@@ -796,6 +841,252 @@ void TableViewRow::RebuildCells()
RefreshRowBackground();
}
+void TableViewRow::RefreshCellToolTips()
+{
+ // A handler can drop the last reference to this row.
+ auto strongThis = get_strong();
+
+ // A drain is already running and owns the replay. Record the request rather than dropping it:
+ // that drain took its targets before this call, so a cell this refresh is about - an editor that
+ // just closed - is not in them.
+ if (m_isRebuildingCells || m_isRefreshingCellToolTips)
+ {
+ m_refreshCellToolTipsPending = true;
+ RefreshCellToolTipsGuarded();
+ return;
+ }
+
+ // Reached directly (invalidate, edit close), so this owns the drain: a rebuild a handler queued
+ // must be replayed here, or the row stays stamped for the previous state. Iterative over the
+ // Core primitives - calling RebuildCells would start a second drain and recurse.
+ bool rebuiltOnLastPass = false;
+ for (int pass = 0; pass < c_maxCellDrainPasses; ++pass)
+ {
+ m_refreshCellToolTipsPending = false;
+ RefreshCellToolTipsGuarded();
+ rebuiltOnLastPass = false;
+
+ if (m_rebuildCellsPending)
+ {
+ m_rebuildCellsPending = false;
+ RebuildCellsCore();
+ rebuiltOnLastPass = true;
+ continue;
+ }
+
+ // A refresh requested from inside the pass (an edit that closed under a handler) needs
+ // another pass: its cell was excluded from the targets this one took.
+ if (!m_refreshCellToolTipsPending)
+ {
+ return;
+ }
+ }
+
+ // Only when the budget ran out mid-rebuild: those cells carry the previous item's tooltips and
+ // nothing else will resolve them. A refresh-only backlog is dropped instead, so app code is
+ // never raised more than c_maxCellDrainPasses times.
+ if (rebuiltOnLastPass)
+ {
+ m_refreshCellToolTipsPending = false;
+ RefreshCellToolTipsGuarded();
+ }
+
+ if (m_rebuildCellsPending || m_refreshCellToolTipsPending)
+ {
+ TVDiag::LogRetailF(L"[TableViewRow] Dropping pending cell work after %d drain passes.",
+ c_maxCellDrainPasses);
+ m_rebuildCellsPending = false;
+ m_refreshCellToolTipsPending = false;
+ }
+}
+
+// Callers run from framework callbacks, where an escaping handler exception is a fail-fast.
+void TableViewRow::RefreshCellToolTipsGuarded()
+{
+ try
+ {
+ RefreshCellToolTipsCore();
+ }
+ catch (...)
+ {
+ TVDiag::LogRetailF(L"[TableViewRow] The cell tooltip pass failed (HRESULT 0x%08X). Continuing.",
+ static_cast(winrt::to_hresult()));
+ }
+}
+
+void TableViewRow::RefreshCellToolTipsCore()
+{
+ // Innermost guard: the drains also test this flag to decide ownership, but the pass must be
+ // non-re-entrant from every entry point because it is what calls app code.
+ if (m_isRefreshingCellToolTips)
+ {
+ return;
+ }
+
+ auto const host = m_cellsHost.get();
+ if (!host)
+ {
+ return;
+ }
+
+ auto strongThis = get_strong();
+ m_isRefreshingCellToolTips = true;
+ auto refreshGuard = wil::scope_exit([this]() { m_isRefreshingCellToolTips = false; });
+
+ auto const owner = GetOwningTableView();
+ // No owner: an owned tooltip can hold app content, so release it rather than pin it in the pool.
+ if (!owner)
+ {
+ ClearOwnedCellToolTips(host);
+ return;
+ }
+
+ auto const ownerImpl = winrt::get_self(owner);
+
+ // Opt-in: with no handler this is one event_source test, unless a previous pass left tooltips.
+ if (!ownerImpl->HasCellToolTipHandler())
+ {
+ if (m_hasOwnedCellToolTips)
+ {
+ ClearOwnedCellToolTips(host);
+ }
+ return;
+ }
+
+ // Snapshot before raising: a handler may mutate Columns or the cell children. Moved to a local
+ // so a nested pass cannot clear it mid-iteration; capacity is handed back.
+ auto targets = std::move(m_toolTipTargets);
+ targets.clear();
+ auto const children = host.Children();
+ const uint32_t count = children.Size();
+ targets.reserve(count);
+ auto const editingWrapper = m_editingCellWrapper.get();
+ for (uint32_t i = 0; i < count; ++i)
+ {
+ // The wrapper, not the display element: it fills the cell and is the UIA cell node.
+ auto const cellWrapper = children.GetAt(i).try_as();
+ if (!cellWrapper)
+ {
+ continue;
+ }
+
+ // An open editor owns its cell; retract rather than skip, or the tooltip is left enabled.
+ if (cellWrapper == editingWrapper)
+ {
+ TableViewDetails::ClearOwnedToolTip(cellWrapper);
+ continue;
+ }
+
+ if (auto const column = GetCellOwningColumn(cellWrapper))
+ {
+ targets.emplace_back(column, cellWrapper);
+ }
+ else
+ {
+ // No resolvable column, so the pass cannot re-resolve this cell. Drop any tooltip the
+ // control owns rather than leave one behind that the owned-tooltip flag will not account
+ // for.
+ TableViewDetails::ClearOwnedToolTip(cellWrapper);
+ }
+ }
+
+ // A handler can throw before any cell is visited, so the flag stays conservatively true unless
+ // the pass completed.
+ bool anyOwned = false;
+ bool passCompleted = false;
+ auto passGuard = wil::scope_exit([&]()
+ {
+ m_hasOwnedCellToolTips = passCompleted ? anyOwned : true;
+ targets.clear();
+ m_toolTipTargets = std::move(targets);
+ });
+
+ auto const dataItem = ownerImpl->UnwrapEditingDataItem(DataContext());
+ bool anyPointerOnly = false;
+ for (auto const& target : targets)
+ {
+ // Covers the raise as well as the apply: a throwing handler must not escape into the
+ // framework callback this pass runs from, and app content may already be parented.
+ try
+ {
+ auto const args = ownerImpl->RaiseCellToolTipRequested(target.first, dataItem);
+ auto const content = args->Content();
+
+ // The editing wrapper is re-read after the raise, not trusted from the snapshot: a
+ // handler can start an edit on this very cell, and applying here would put a tooltip
+ // over a live editor.
+ if (target.second == m_editingCellWrapper.get())
+ {
+ TableViewDetails::ClearOwnedToolTip(target.second);
+ continue;
+ }
+
+ // Non-string content with no accessible text is pointer-only. Reported once per pass
+ // rather than per cell, since a whole column would otherwise log on every scroll.
+ anyPointerOnly = anyPointerOnly ||
+ (content && args->AutomationHelpText().empty() && !TableViewDetails::TryGetString(content));
+
+ const bool owned = content
+ ? TableViewDetails::SetOwnedToolTip(
+ target.second,
+ content,
+ args->AutomationHelpText(),
+ winrt::PlacementMode::Mouse)
+ : (TableViewDetails::ClearOwnedToolTip(target.second), false);
+
+ anyOwned = anyOwned || owned;
+ }
+ catch (...)
+ {
+ // Drop this cell's tooltip rather than leave the previous item's text on it: once the
+ // row recycles that content belongs to a different item.
+ try
+ {
+ TableViewDetails::ClearOwnedToolTip(target.second);
+ }
+ catch (...)
+ {
+ // Assume the element still holds something: the row must revisit it, not skip it.
+ anyOwned = true;
+ }
+
+ TVDiag::LogRetailF(L"[TableViewRow] Resolving a cell tooltip failed (HRESULT 0x%08X).",
+ static_cast(winrt::to_hresult()));
+ }
+ }
+
+ if (anyPointerOnly)
+ {
+ TVDiag::LogRetailF(L"[TableViewRow] A cell tooltip is pointer-only: its content is not text and "
+ L"CellToolTipRequestedEventArgs.AutomationHelpText was not set.");
+ }
+
+ passCompleted = true;
+}
+
+void TableViewRow::ReleaseCellToolTips()
+{
+ if (auto const host = m_cellsHost.get(); host && m_hasOwnedCellToolTips)
+ {
+ ClearOwnedCellToolTips(host);
+ }
+}
+
+void TableViewRow::ClearOwnedCellToolTips(const winrt::Panel& host)
+{
+ auto const children = host.Children();
+ const uint32_t count = children.Size();
+ for (uint32_t i = 0; i < count; ++i)
+ {
+ if (auto const cellWrapper = children.GetAt(i).try_as())
+ {
+ TableViewDetails::ClearOwnedToolTip(cellWrapper);
+ }
+ }
+
+ m_hasOwnedCellToolTips = false;
+}
+
// Installs a generated display element as a cell's content, including the ContentPresenter wiring a
// template column needs. Shared by the cell rebuild and by the post-commit refresh, because
// GenerateElement alone is NOT a complete cell - forgetting the second half leaves a template
@@ -984,6 +1275,8 @@ bool TableViewRow::BeginCellEdit(const winrt::TableViewColumn& column, const win
m_editingColumn.set(column);
m_editingCellWrapper.set(cellWrapper);
+ // An editor owns its cell; a tooltip over a live text box is noise.
+ TableViewDetails::ClearOwnedToolTip(cellWrapper);
m_editingElement.set(editingElement);
// The column decides how its editor is primed - focus, caret, selection are editor-specific,
@@ -1076,6 +1369,10 @@ void TableViewRow::EndCellEdit(winrt::TableViewEditAction action)
m_editingCellWrapper.set(nullptr);
m_editingElement.set(nullptr);
m_editingDisplayElement.set(nullptr);
+
+ // The tooltip pass retracts the editing cell's tooltip while the editor is open; nothing else
+ // brings it back, so re-resolve now that the cell is a display cell again.
+ RefreshCellToolTips();
}
void TableViewRow::AbandonCellEdit()
@@ -1093,6 +1390,9 @@ void TableViewRow::AbandonCellEdit()
m_editingCellWrapper.set(nullptr);
m_editingElement.set(nullptr);
m_editingDisplayElement.set(nullptr);
+
+ // The cell is a display cell again; re-resolve the tooltip the edit retracted.
+ RefreshCellToolTips();
}
// Pointer entry point for editing, and the only place a pointer establishes the current cell.
diff --git a/controls/dev/TableView/TableViewRow.h b/controls/dev/TableView/TableViewRow.h
index 9c19e666ad..2b8dbd0562 100644
--- a/controls/dev/TableView/TableViewRow.h
+++ b/controls/dev/TableView/TableViewRow.h
@@ -9,6 +9,9 @@
#include "TableViewRow.g.h"
#include "TableViewRow.properties.h"
+#include
+#include
+
class TableViewRow :
public ReferenceTracker,
public TableViewRowProperties
@@ -55,6 +58,12 @@ class TableViewRow :
void RefreshDensity();
// Rebuild realized cells when column content changes at runtime.
void RefreshCells();
+
+ // Internal: re-resolves this row's cell tooltips against its current data item.
+ void RefreshCellToolTips();
+ // Releases control-owned cell tooltips (recycle-out).
+ void ReleaseCellToolTips();
+
// Apply a column's current visibility to this row's matching cell (the row owns its cells,
// so TableView asks the row instead of reaching into the row's cell panel). The visibility is
// passed in (snapshotted once by the caller) so header and all rows apply the same value.
@@ -128,6 +137,11 @@ class TableViewRow :
const winrt::Microsoft::UI::Xaml::DependencyPropertyChangedEventArgs& args);
void RebuildCells();
+ // The guarded bodies. RebuildCells and RefreshCellToolTips drain over these.
+ void RebuildCellsCore();
+ void RefreshCellToolTipsCore();
+ void RefreshCellToolTipsGuarded();
+ void ClearOwnedCellToolTips(const winrt::Panel& host);
// Coalesces a burst of Columns-collection changes into a single cell rebuild on the next
// dispatcher tick (each realized row observes Columns, so N bulk edits would otherwise cost N
@@ -154,6 +168,20 @@ class TableViewRow :
// Prevent DataContextChanged re-entry while RebuildCells updates child DCs.
bool m_isRebuildingCells{ false };
+ // Set while the tooltip pass is running, so a nested RebuildCells records instead of raising.
+ bool m_isRefreshingCellToolTips{ false };
+ // A rebuild requested while the guard was held; replayed once the guard drops.
+ bool m_rebuildCellsPending{ false };
+ // A tooltip refresh requested while a pass was running (an edit that closed under a handler).
+ // That pass took its targets first, so the cell it is about needs another pass.
+ bool m_refreshCellToolTipsPending{ false };
+ // Whether any cell on this row carries a control-created tooltip, so the pass stays free when
+ // the feature is unused but still retracts tooltips after the last handler goes.
+ bool m_hasOwnedCellToolTips{ false };
+ // Reused per pass so the virtualized path does not allocate on every recycle. Plain refs rather
+ // than tracker_ref: strictly stack-scoped, cleared by the pass guard on every path including
+ // throw, so it never outlives the pass.
+ std::vector> m_toolTipTargets{};
// Set while a coalesced RebuildCells is pending on the dispatcher (Columns-vector-changed burst).
bool m_rebuildCellsQueued{ false };
diff --git a/controls/dev/TableView/TableViewToolTipHelpers.h b/controls/dev/TableView/TableViewToolTipHelpers.h
new file mode 100644
index 0000000000..6c521d6897
--- /dev/null
+++ b/controls/dev/TableView/TableViewToolTipHelpers.h
@@ -0,0 +1,197 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+#pragma once
+
+#include "GlobalDependencyProperty.h"
+
+#include
+
+namespace TableViewDetails
+{
+ // The tooltip the control attached, and the exact HelpText it published. The published text is
+ // not always the tooltip's content, so it is recorded rather than re-derived.
+ struct CellToolTipRecord : winrt::implements
+ {
+ winrt::ToolTip ToolTip{ nullptr };
+ winrt::hstring PublishedHelpText{};
+ };
+
+ inline GlobalDependencyProperty s_cellToolTipRecordProperty{ nullptr };
+
+ inline winrt::DependencyProperty EnsureCellToolTipRecordProperty()
+ {
+ if (!s_cellToolTipRecordProperty)
+ {
+ s_cellToolTipRecordProperty = InitializeDependencyProperty(
+ L"TableViewCellToolTipRecord",
+ winrt::name_of(),
+ winrt::name_of(),
+ true /* isAttached */,
+ nullptr /* defaultValue */,
+ nullptr /* propertyChangedCallback */);
+ }
+
+ return s_cellToolTipRecordProperty;
+ }
+
+ // Called from the generated ClearTypeProperties so a XAML re-init re-registers against the new core.
+ inline void ClearCellToolTipProperties()
+ {
+ s_cellToolTipRecordProperty = nullptr;
+ }
+
+ inline winrt::com_ptr GetRecord(const winrt::FrameworkElement& element)
+ {
+ if (!element || !s_cellToolTipRecordProperty)
+ {
+ return nullptr;
+ }
+
+ return element.GetValue(s_cellToolTipRecordProperty).try_as();
+ }
+
+ inline void ForgetRecord(const winrt::FrameworkElement& element)
+ {
+ if (s_cellToolTipRecordProperty)
+ {
+ element.ClearValue(s_cellToolTipRecordProperty);
+ }
+ }
+
+ inline std::optional TryGetString(const winrt::IInspectable& value)
+ {
+ if (auto const propertyValue = value ? value.try_as() : nullptr;
+ propertyValue && propertyValue.Type() == winrt::PropertyType::String)
+ {
+ return propertyValue.GetString();
+ }
+
+ return std::nullopt;
+ }
+
+ inline void RetractPublishedHelpText(const winrt::FrameworkElement& element, CellToolTipRecord& record)
+ {
+ if (!record.PublishedHelpText.empty() &&
+ winrt::AutomationProperties::GetHelpText(element) == record.PublishedHelpText)
+ {
+ winrt::AutomationProperties::SetHelpText(element, winrt::hstring{});
+ }
+
+ record.PublishedHelpText = {};
+ }
+
+ // Neutralized in place rather than detached, so a recycled cell reuses the ToolTip instead of
+ // allocating a Control per cell per scroll. Matches TabViewItem.
+ inline void ClearOwnedToolTip(const winrt::FrameworkElement& element)
+ {
+ auto const record = GetRecord(element);
+ if (!record)
+ {
+ return;
+ }
+
+ RetractPublishedHelpText(element, *record);
+
+ if (record->ToolTip && record->ToolTip == winrt::ToolTipService::GetToolTip(element).try_as())
+ {
+ // Close before dropping content: clearing an open tooltip's content removes a live
+ // popup's child, the shape behind the reentrant CPopup::RemoveChild crash.
+ record->ToolTip.IsEnabled(false);
+ if (record->ToolTip.IsOpen())
+ {
+ record->ToolTip.IsOpen(false);
+ }
+ record->ToolTip.Content(nullptr);
+ }
+ else
+ {
+ ForgetRecord(element);
+ }
+ }
+
+ // Applies app-supplied content to a control-created cell wrapper, returning whether the element
+ // is left carrying a control-owned tooltip. Never touches a tooltip the app set itself.
+ inline bool SetOwnedToolTip(
+ const winrt::FrameworkElement& element,
+ const winrt::IInspectable& content,
+ const winrt::hstring& helpTextOverride,
+ winrt::PlacementMode placement)
+ {
+ if (!element)
+ {
+ return false;
+ }
+
+ auto record = GetRecord(element);
+ // The raw value, not a ToolTip-narrowed one: ToolTipService stores whatever the app set, and
+ // a bare string would otherwise read as an empty slot.
+ auto const existingValue = winrt::ToolTipService::GetToolTip(element);
+ auto const owned = (record && record->ToolTip && record->ToolTip == existingValue.try_as())
+ ? record->ToolTip : nullptr;
+
+ if (existingValue && !owned)
+ {
+ if (record)
+ {
+ RetractPublishedHelpText(element, *record);
+ ForgetRecord(element);
+ }
+ return false;
+ }
+
+ // A ToolTip as content would render nested inside ours, and the control owns placement.
+ auto const text = TryGetString(content);
+ if (!content || (text && text->empty()) || content.try_as())
+ {
+ ClearOwnedToolTip(element);
+ return false;
+ }
+
+ EnsureCellToolTipRecordProperty();
+ if (!record)
+ {
+ record = winrt::make_self();
+ element.SetValue(s_cellToolTipRecordProperty, *record);
+ }
+
+ RetractPublishedHelpText(element, *record);
+
+ if (owned)
+ {
+ // Neutralize first: a throwing assignment must not leave the previous item's content live.
+ owned.IsEnabled(false);
+ if (owned.IsOpen())
+ {
+ owned.IsOpen(false);
+ }
+ owned.Content(nullptr);
+ owned.Content(content);
+ owned.Placement(placement);
+ owned.IsEnabled(true);
+ }
+ else
+ {
+ winrt::ToolTip toolTip;
+ toolTip.Content(content);
+ toolTip.Placement(placement);
+
+ // Recorded before attaching: a throw then reads as "not ours" rather than orphaning a
+ // tooltip nothing can clear.
+ record->ToolTip = toolTip;
+ winrt::ToolTipService::SetToolTip(element, toolTip);
+ }
+
+ // Published unconditionally; TableViewCellAutomationPeer suppresses it at UIA query time if
+ // it merely repeats the cell's own text. Comparing here would race the cell's binding.
+ auto const helpText = !helpTextOverride.empty() ? helpTextOverride : (text ? *text : winrt::hstring{});
+ if (!helpText.empty() &&
+ winrt::AutomationProperties::GetHelpText(element).empty())
+ {
+ winrt::AutomationProperties::SetHelpText(element, helpText);
+ record->PublishedHelpText = helpText;
+ }
+
+ return true;
+ }
+}
diff --git a/controls/dev/dll-tabular/XamlMetadataProviderGenerated.h b/controls/dev/dll-tabular/XamlMetadataProviderGenerated.h
index 64edaaf0b1..477cd63486 100644
--- a/controls/dev/dll-tabular/XamlMetadataProviderGenerated.h
+++ b/controls/dev/dll-tabular/XamlMetadataProviderGenerated.h
@@ -853,6 +853,7 @@ std::wstring_view c_knownNamespacePrefixes[] =
#include "TableViewGroupHeader.properties.h"
#include "TableViewRow.properties.h"
#include "TableViewTemplateColumn.properties.h"
+#include "TableViewToolTipHelpers.h"
namespace {
@@ -865,6 +866,7 @@ void ClearTypeProperties()
TableViewGroupHeaderProperties::ClearProperties();
TableViewRowProperties::ClearProperties();
TableViewTemplateColumnProperties::ClearProperties();
+ TableViewDetails::ClearCellToolTipProperties();
}
}
diff --git a/controls/dev/dll-tabular/XamlMetadataProviderGenerated.tt b/controls/dev/dll-tabular/XamlMetadataProviderGenerated.tt
index 3d1ccec636..30e46b123e 100644
--- a/controls/dev/dll-tabular/XamlMetadataProviderGenerated.tt
+++ b/controls/dev/dll-tabular/XamlMetadataProviderGenerated.tt
@@ -205,7 +205,13 @@ struct Entry
{
WriteLine("#include \"" + type.Name + ".properties.h\"");
}
-
+
+ // HAND-EDIT (keep when syncing this template): TableView's cell-tooltip ownership property is
+ // registered lazily outside the generated TableViewProperties, so the generated file owns its
+ // include as well as the clear call below -- otherwise the generated header would only compile
+ // when its includer happened to add it first.
+ WriteLine("#include \"TableViewToolTipHelpers.h\"");
+
// The Tabular controls DLL hosts only TableView + its primitives; the MUXC-only helpers
// (AutoSuggestBoxHelper / ComboBoxHelper / RecyclePool / RevealBrush) that the full MUXC
// provider manually includes do not exist in this binary, so they are intentionally omitted.
@@ -224,6 +230,11 @@ namespace {
// The Tabular controls DLL hosts only TableView + its primitives; the MUXC-only helpers
// (AutoSuggestBoxHelper / ComboBoxHelper / RecyclePool / RevealBrush) are intentionally omitted.
+ //
+ // HAND-EDIT (keep when syncing this template): TableView's cell-tooltip ownership property
+ // is registered lazily outside the generated TableViewProperties, so it needs clearing here
+ // too or a XAML re-init would keep handing out a registration from the dead core.
+ WriteLine("TableViewDetails::ClearCellToolTipProperties();");
});
WriteLine("");
#>
diff --git a/docs/api-specs/TableView/TableView-spec.md b/docs/api-specs/TableView/TableView-spec.md
index bc797aae4d..e4c2ac64c6 100644
--- a/docs/api-specs/TableView/TableView-spec.md
+++ b/docs/api-specs/TableView/TableView-spec.md
@@ -445,6 +445,7 @@ Named to match `ItemsView`, which ships `Select` / `Deselect` / `IsSelected` ove
| Event | Args | Description |
|---|---|---|
+| `CellToolTipRequested` | `TableViewCellToolTipRequestedEventArgs` | Raised when a cell's tooltip is resolved — on realization, on cell rebuild, when a cell edit closes, and on `InvalidateCellToolTips()`. May be raised repeatedly for the same cell and item, so handlers should be cheap and side-effect free. Opt-in: with no handler attached the control does no per-cell work. `InvalidateCellToolTips()` queues a coalesced re-resolve for every realized cell, for handlers attached after realization, when the underlying data changes, or to retract tooltips already showing after the last handler is removed; it is re-entrancy-safe, but a handler that invalidates on every raise is dropped after a few passes. |
| `SelectionChanged` | `SelectionChangedEventArgs` | Raised after the selection has settled. Reading `SelectedItem`, `SelectedIndex`, or a row's `IsSelected` inside the handler observes the new state. Replacing a selection raises **one** event carrying both the removed and the added item. |
Selection is raised only for real changes: a re-select of the already-selected row, or an index shift caused by a collection reshape, does not raise `SelectionChanged`.
@@ -647,6 +648,17 @@ How an edit is being closed.
|---|---|
| `TableViewBeginningEditEventArgs` | `Item`, `Column` (read-only); `Cancel` (settable) |
| `TableViewCellEditEndingEventArgs` | `Item`, `Column`, `EditAction` (read-only); `Cancel` (settable) |
+| `TableViewCellToolTipRequestedEventArgs` | `Item`, `Column` (read-only); `Content`, `AutomationHelpText` (settable) |
+
+### Cell tooltip accessibility
+
+The control owns the `ToolTip`; `Content` is its content, not a `ToolTip` to attach. A `UIElement` returned as `Content` is parented by that cell's `ToolTip`, so return a string or a fresh element per raise — the same instance handed to a second cell cannot be parented twice and is dropped.
+
+- The tooltip text is published as the cell's `AutomationProperties.HelpText`, and retracted on recycle and when a cell edit begins.
+- Publication is suppressed when the text equals the cell's own UIA text, so Narrator does not read it twice.
+- The popup is **pointer-only**: cell focus in `TableView` is row-level, so there is no cell element for the framework's keyboard-tooltip path to fire on. The UIA pairing is what serves keyboard and screen-reader users, which is why it is not optional.
+- Placement is control-owned and fixed (`PlacementMode.Mouse`), matching `TabViewItem`. An app needing different placement uses a tooltip inside its own cell content template.
+- Setting `AutomationHelpText` is **required** when `Content` is not a string: non-string content cannot be stringified, and the cell wrapper the tooltip attaches to is internal, so an app cannot set `HelpText` on it.
## Selection event args
@@ -730,6 +742,15 @@ namespace Microsoft.UI.Xaml.Controls.Tabular
Boolean Cancel;
};
+ [MUX_PREVIEW, webhosthidden]
+ runtimeclass TableViewCellToolTipRequestedEventArgs
+ {
+ Object Item { get; };
+ Microsoft.UI.Xaml.Controls.Tabular.TableViewColumn Column { get; };
+ Object Content { get; set; };
+ String AutomationHelpText { get; set; };
+ };
+
[MUX_PREVIEW, webhosthidden]
runtimeclass TableViewCellEditEndingEventArgs
{
@@ -837,6 +858,8 @@ namespace Microsoft.UI.Xaml.Controls.Tabular
Boolean IsSelected(Int32 index);
void DeselectAll();
+ event Windows.Foundation.TypedEventHandler CellToolTipRequested;
+ void InvalidateCellToolTips();
event Windows.Foundation.TypedEventHandler SelectionChanged;
static Microsoft.UI.Xaml.DependencyProperty ItemsSourceProperty { get; };
diff --git a/docs/design-notes/TabularControls/TableView-functional-spec.md b/docs/design-notes/TabularControls/TableView-functional-spec.md
index 9390d46539..cd8960a269 100644
--- a/docs/design-notes/TabularControls/TableView-functional-spec.md
+++ b/docs/design-notes/TabularControls/TableView-functional-spec.md
@@ -75,6 +75,17 @@ Each item notes the MLP/v1 vs deferred status and the delivering PR.
- Light / Dark / High Contrast theme tokens + inline fallbacks (`#29FFFFFF` dark gridline). — **PR2** (full self-themed Theme-XBF emission deferred)
- Cell-level styling: custom cells via `TableViewTemplateColumn` + built-in text-cell defaults (left, vertically centered). A public per-column alignment/weight API — **deferred (PR3+)**.
+### Tooltips
+- Cell: opt-in via `CellToolTipRequested`; the app supplies content per cell on demand. No handler means no tooltip and no per-cell cost. Needed because text cells render with `CharacterEllipsis` and no wrapping, so over-wide values are otherwise unreadable. — **PR3**
+- Raised as each cell's tooltip is resolved: on realization, on cell rebuild, when a cell edit closes, and on `InvalidateCellToolTips()`.
+- Author precedence: a tooltip set inside a cell's own content template opens over that content; the control's tooltip covers the rest of the cell, and the control never touches a tooltip it did not attach.
+- Content: a string, or any content a `ToolTip` can host. A `UIElement` is parented by that cell's `ToolTip`, so a handler returns a fresh element per raise — the same instance handed to a second cell cannot be parented twice and is dropped.
+- Accessibility: the tooltip text is published as the cell's `AutomationProperties.HelpText` unless the cell already reports it, so the information is not pointer-only and is not announced twice. For content UIA cannot read the handler sets `AutomationHelpText`, since the cell wrapper is not reachable from the app.
+- Recycling: a recycled row never shows a previous item's cell tooltip, including after the last handler is removed. `InvalidateCellToolTips()` queues a coalesced re-resolve of every realized cell when a handler is attached late, when the underlying data changes, or to retract tooltips already showing after the last handler is removed. It is re-entrancy-safe, but a handler that invalidates on every raise is dropped after a few passes.
+- Column-header and group-header tooltips are **deferred**: the header band is `IsHitTestVisible="False"` (`TableView.xaml`) so a header tooltip could never open, and grouping is not yet enabled. Both land with the work that makes those surfaces interactive.
+
+> Tooltips are **not** gated on text truncation. No WinUI control keys tooltips off `IsTextTrimmed`; the shipped pattern is to gate on a cheap content predicate (non-empty string) or an explicit opt-in.
+
### Accessibility (UIA)
- Grid/Table peers; Row peer (`SelectionItem` + `GridItem`); ColumnHeader peer (`Invoke` → sort). — **PR2** read-only Grid/Table · **PR3** Selection + sort invoke
- Narrator, keyboard navigation parity, sort announcements. — **PR2** nav · **PR3** sort