Skip to content

Tabular: add opt-in per-cell tooltips to TableView - #11669

Draft
Hitesh Kumar (hiteshkrmsft) wants to merge 6 commits into
mainfrom
user/hik/tableview-cell-tooltips
Draft

Tabular: add opt-in per-cell tooltips to TableView#11669
Hitesh Kumar (hiteshkrmsft) wants to merge 6 commits into
mainfrom
user/hik/tableview-cell-tooltips

Conversation

@hiteshkrmsft

Copy link
Copy Markdown
Contributor

Adds an opt-in per-cell tooltip to TableView.

Text cells render with CharacterEllipsis and no wrapping, so a value wider than its column is unreadable and an app has no way to surface the full text. CellToolTipRequested gives it one: the handler receives the row's Item and the cell's Column, and sets Content.

It is opt-in — with no handler attached the control does no per-cell work and allocates nothing — and it is resolved per cell as rows realize and recycle, so a virtualized table only pays for what is on screen.

API

[MUX_PREVIEW][webhosthidden]
runtimeclass TableViewCellToolTipRequestedEventArgs
{
    Object Item { get; };
    MU_XC_NAMESPACE.TableViewColumn Column { get; };
    Object Content { get; set; };
    String ToolTipHelpText { get; set; };
};

// on TableView
event TypedEventHandler<TableView, TableViewCellToolTipRequestedEventArgs> CellToolTipRequested;
void InvalidateCellToolTips();
table.CellToolTipRequested += (s, e) =>
{
    if (e.Item is Order order && e.Column.Header as string == "Notes")
    {
        e.Content = order.Notes;   // full text, untruncated
    }
};

Design

The control owns the ToolTip. Content is the tooltip's content, not a ToolTip to attach. ToolTipService rebinds a ToolTip's owner and container on every registration, so one instance handed back for several cells would leave them contending for a single owner slot and one set of event tokens. The control creates the object and sets PlacementMode.Mouse, since cells are arbitrarily wide and element-relative placement can land the tip far from the pointer.

Ownership is recorded, not inferred. A private attached record holds the ToolTip the control attached and the exact HelpText it published, compared against what is actually on the element. A tooltip the app replaces is detected and left alone; the control only ever touches what it put there. Recording before attaching means a failure degrades to "not ours" rather than orphaning a tooltip nothing can clear.

Recycling. A recycled row never shows or announces a previous item's tooltip, including after the last handler is removed — retraction does not depend on a handler still being attached. A row recycled out of view releases app-supplied content rather than pinning it in the repeater's pool. Control-created tooltips are neutralized in place rather than detached, so scrolling reuses the object instead of allocating a Control per cell per scroll, matching TabViewItem.

Re-entrancy. The event reaches app code from the cell realization path, so the pass snapshots its targets rather than walking the live children collection, and holds a strong reference across the raise. A rebuild triggered from a handler is recorded and replayed after the pass rather than running underneath it. InvalidateCellToolTips() is coalesced onto the dispatcher rather than raising synchronously, so it is safe to call from inside a handler.

Accessibility

The tooltip text is published as the cell's AutomationProperties.HelpText unless the cell already reports that text, so the information reaches assistive technology exactly once instead of being pointer-only or announced twice. The comparison uses the same cell-text helper TableViewCellAutomationPeer uses to compose its name, so the two cannot drift.

When Content is not a string it cannot be stringified, and the cell wrapper the tooltip attaches to is internal — an app cannot set HelpText on it. ToolTipHelpText is the supported way to supply the accessible equivalent.

The popup itself 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, and that is why it is not optional.

Deferred

Column-header and group-header tooltips. PART_HeaderScroller is IsHitTestVisible="False" and hosts every header cell, and it is scrolled programmatically from the body — a header tooltip could never open, and making the band hit-testable is template work beyond this change. Grouping is not wired into TableView on main yet. Both surfaces land with the work that makes them interactive.

Notes for reviewers

Generated files. controls/dev/Generated/TableView.properties.* and controls/dev/dll-tabular/XamlMetadataProviderGenerated.h are codegen output and are regenerated here rather than hand-edited. The regeneration also picks up GroupHeaderTemplate and the group-header/group-info entries, which are already declared in the IDL but had not been regenerated into the checked-in output — that churn is not from this change. The generated header is committed rather than left to the incremental codegen target, whose Inputs/Outputs timestamp comparison can be skipped on a fresh clone.

Tooltip property teardown. The ownership property is registered lazily, outside the generated TableViewProperties, so XamlMetadataProviderGenerated.tt now emits its clear call into ClearTypeProperties() and emits its own #include. Without it a XAML re-initialization would keep handing out a registration from the torn-down core. Only controls/dev/dll-tabular is affected; the MUXC provider has its own template and does not host TableView.

Testing. There is no TableView test project in the repo yet, so this change ships without automated coverage; the behavioural contract is written down in docs/design-notes/TabularControls/TableView-functional-spec.md and the API in docs/api-specs/TableView/TableView-spec.md so tests have something to bind to. The control DLL builds clean, but the tooltip behaviour has not been exercised at runtime — worth weighting in review, particularly the recycling and UIA-pairing paths.

TableView text cells render with CharacterEllipsis and no wrapping, so a value wider than
its column is unreadable and there is no way for an app to surface the full text.

Adds a CellToolTipRequested event: the handler receives the row's Item and the cell's Column
and sets Content, which the control shows as that cell's tooltip. It is opt-in - with no
handler attached the control does no per-cell work and allocates nothing - and it is resolved
per cell as rows realize and recycle, so a virtualized table only pays for what is on screen.

The control owns the ToolTip object. Content is the tooltip's content, not a ToolTip to
attach: ToolTipService rebinds a ToolTip's owner and container on every registration, so a
single instance handed back for several cells would leave them contending for one owner slot.

Accessibility: the tooltip text is published as the cell's AutomationProperties.HelpText
unless the cell already reports that text, so assistive technology receives the information
exactly once. When Content is not a string, the handler sets ToolTipHelpText to supply the
accessible equivalent - the cell wrapper the tooltip is attached to is internal, so an app
cannot set HelpText on it directly.

Recycling: the control tracks the tooltip it attached and the exact HelpText it published, so
a recycled row never shows or announces a previous item's tooltip, including after the last
handler is removed. InvalidateCellToolTips() re-resolves every realized cell for handlers
attached after realization, or when the data behind the tooltips changes; it is coalesced onto
the dispatcher so it is safe to call from inside a handler.

Column-header and group-header tooltips are deferred. The header band is IsHitTestVisible=
"False" and is scrolled programmatically from the body, so a tooltip there could never open;
both surfaces land with the work that makes them interactive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the needs-triage Issue needs to be triaged by the area owners label Aug 27, 2026
Contain handler exceptions per row on the deferred invalidate path, which previously let a
throwing handler escape into a dispatcher callback.

Drain a handler-queued rebuild from refreshes reached directly (invalidate, edit close);
only the rebuild path drained, leaving those rows stamped for the previous state.

Test ownership against the raw attached value: ToolTipService stores whatever the app set,
and a bare string was read as an empty slot and overwritten. Reject a ToolTip as Content
rather than nesting it, and correct the IDL, which claimed it overrides placement.

Keep the invalidate queued flag set across the pass and record a follow-up, so a handler
calling it cannot queue a callback every tick.

Release tooltip content from OnRowElementClearing: the release path was unreachable because
recycle-out never rebuilds, so app content stayed alive in the pool.

Retract the tooltip when an edit begins and re-resolve when one is abandoned, covering
forced closes that do not rebuild.

Keep the owned-tooltip flag true unless the pass completed, so a handler throwing on the
first cell cannot hide earlier tooltips from cleanup.

Resolve the cell's UIA text only when there is something to publish; it can allocate an
automation peer.
The rebuild drain called the public RefreshCellToolTips, which drains as well, so a
handler that dirtied the row started a nested drain with a fresh counter instead of
tripping the cap. Depth was bounded only by how long the app kept mutating, and the
"dropping a pending cell rebuild" diagnostic was unreachable on the path it was
written for. Both loops now run over the Core primitives and share one pass cap, so a
handler gets the same number of replays whichever entry point it reaches.

Bound the deferred invalidate the same way. A handler calling InvalidateCellToolTips
on every raise re-armed the follow-up forever, queuing a dispatcher callback per tick
and consuming a core; beyond the cap the request is now dropped with a diagnostic.

Hold the coalescing flag across the synchronous fallbacks, which ran without it, so a
handler reaching them re-entered and recursed rather than recording a follow-up. Clear
the flag on the throw path too: it was a bare assignment, and a pass that threw left
every later invalidate coalescing into a callback that would never run.

Raise CellToolTipRequested inside the try that applies its result. Only the apply was
contained, so a throwing handler escaped through the edit-close paths, which run from
key and focus callbacks.

Test the cheap conditions before resolving the cell's UIA text, which can allocate an
automation peer for non-text content, and correct the IDL: calling the invalidate from
a handler is re-entrancy-safe, but it is not free to call unconditionally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rename CellToolTipRequestedEventArgs.ToolTipHelpText to AutomationHelpText. The value is
published verbatim as the cell's UIA HelpText, and the old name read as help text belonging
to the tooltip.

Resolve the cell's UIA text without creating an automation peer on the tooltip path. The
duplicate-suppression comparison ran on every realization and every invalidate, so a
template column allocated a peer per cell with no UIA listener attached.

Refresh tooltips once more when a drain runs out of passes, so cells rebuilt on the last
pass do not keep the previous item's tooltips, and the drop diagnostic can be reached.

Make the synchronous invalidate fallback terminal: it now drops a handler-requested
follow-up with a diagnostic rather than silently, and resets the pass counter so a later
invalidate is not capped early.

Correct two IDL claims: the accessible text is suppressed when the cell already reports it,
and the invalidate does run synchronously when there is no dispatcher.

Document the tooltip accessibility contract in the API spec, record that the event is also
raised when a cell edit closes, and state that content returned as a UIElement is parented
by that cell's tooltip and cannot be shared across cells.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d-edits

Non-string tooltip content with no AutomationHelpText reaches assistive technology through nothing but the popup. The control does not synthesize the text - no XAML control derives HelpText from a content peer, and a panel's peer name is empty in the common case - so it reports the condition instead of failing silently. Logged once per pass rather than per cell, since a whole column would otherwise log on every scroll.

Mark the two hand-edited lines in the Tabular metadata provider template, which a future sync from the MUXC template would otherwise drop silently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolves additive conflicts with the TableView data-shaping port (#11644) and the ResizeGripper primitive (#11640): both sides' entries are kept in TableView.vcxitems, the Sorting and Tooltips IDL blocks sit side by side, and the tooltip event-args forward declaration joins the shaping helpers' in TableView.h.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-triage Issue needs to be triaged by the area owners

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant