Files
microsoft-ui-xaml/controls/dev/TableView/TableView_ContextMenu.cpp
T
Dipesh KumarandCopilot 09cfc8ff3f TableView: context menu support (cell, header, row and table flyouts) (#12306)
## Fixes

Brings the TableView context menu work (reviewed in #12299, merged into
`user/dipesh/feature/tableview-experimental`) into main.

## PR Type

- [ ] Bugfix
- [x] Feature
- [ ] Code style update (formatting, renaming)
- [ ] Refactoring (no functional changes, no API changes)
- [ ] Build related changes
- [ ] Documentation content changes
- [ ] Other (please describe):

## Description

Adds context menu support to TableView for cells, column headers, rows,
group headers and the table itself, from mouse, touch, keyboard
(Shift+F10 / Menu key) and UI Automation.

### Current Behavior
TableView has no per-column menus. A right-click only shows whatever
`ContextFlyout` the framework finds on the hit element, keyboard and UIA
requests have no defined target, and menus do not get the row's item as
their DataContext.

### New Behavior
- **New API (preview):** `TableViewColumn.CellContextFlyout` and
`TableViewColumn.HeaderContextFlyout` (+ DP properties).
- **Resolution, first match wins:**
- Cell: a `ContextFlyout` inside the cell content → `CellContextFlyout`
→ the row's `ContextFlyout` → `TableView.ContextFlyout`.
- Header: a `ContextFlyout` inside the `HeaderTemplate` →
`HeaderContextFlyout` → `TableView.ContextFlyout`.
- Rows and group headers get menus from implicit `TableViewRow` /
`TableViewGroupHeader` styles; a group header's DataContext is its
`TableViewGroupInfo`.
- Menu items inherit the requested row's item as DataContext, so one
shared menu works for every row.
- Opening a menu focuses the target cell/row and never changes
selection. Keyboard and UIA requests open at the focused cell.
- No TableView menu opens while a cell is editing. Nested TableViews /
items hosts and text controls' default flyouts are not picked up by the
outer table.
- Automation peers (row, cell, header, group header, table) implement
`ShowContextMenu`; pooled/collapsed elements open nothing.
- Editing: focus moving into a flyout the editor owns (attached flyout,
button flyout, picker) keeps the edit open, including while focus rests
on the flyout's `Popup`; focus in an unrelated popup commits.
- Sample app: new **Context menus** page; README updated.
- Tests: context menu API tests, interaction tests, editing focus tests,
shared interaction test helpers.

## Customer Impact

User-facing: apps can give TableView cells, headers and rows their own
context menus with no code-behind wiring. Additive, preview API.

## Regression Potential

- [ ] Low risk — isolated change, limited scope
- [x] Medium risk — touches shared components or public APIs
- [ ] High risk — architectural or breaking API change

New public properties on `TableViewColumn`. Touches row/cell focus on
context requests, editing focus-loss commit, and TableView automation
peers.

## How Has This Been Tested?

- [x] I have performed a self-review of my own code
- [x] I have added tests to cover my changes
- [x] Existing tests pass locally

Builds (x64chk, 0 errors):
- `.\initrun.ps1 .\build.cmd /q` — passed
- `.\initrun.ps1 msb /q /m
controls\test\MUXControlsTestApp\MUXControlsTestApp.csproj
/p:Platform=x64 /p:Configuration=Debug` — passed
- `.\initrun.ps1 msb /q /m
controls\test\MUXControls.Test\MUXControls.Test.csproj /p:Platform=x64
/p:Configuration=Debug` — passed
- `.\initrun.ps1 msb /q /restore
Samples\TableViewSampleApp\TableViewSampleApp.csproj /p:Platform=x64
/p:WinUIVersion=3.0.0-dev` — passed

TAEF on a test VM (115 tests: context menu API + interaction, editing
API, hierarchy interaction), run in 9 batches:
- `.\initrun.ps1 .\tools\run-tests-on-vm.ps1 -VMName winui-test2
-SkipPayload * -fromFile:batchN.txt` — 113/115 passed on the first run.
The 2 failures (`VerifyFocusInEditorOwnedFlyoutKeepsEditOpen`,
`PressAndHoldOnCellOpensCellMenu`) were fixed (product + test) and the
rerun passed 4/4 including neighbouring tests.

Runtime: sample app Context menus and Hierarchy pages navigated through
UIA after the fixes; app stays up and tables render.

## Screenshots (if appropriate)

N/A

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 75efebd4-6de1-4fe8-b075-fab226b722c6
Copilot-Session: 128a3e81-37b9-46df-b080-757f110ae417
2026-10-20 15:35:44 +05:30

502 lines
16 KiB
C++

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
#include "pch.h"
#include "common.h"
#include "TableView.h"
#include "TableViewRow.h"
#include "TableViewColumn.h"
#include "TableViewCellsPanel.h"
#include "SharedHelpers.h"
bool TableView::IsContextMenuColumn(winrt::TableViewColumn const& column) const
{
if (!column || column.Visibility() != winrt::Visibility::Visible)
{
return false;
}
auto const owner = winrt::get_self<TableViewColumn>(column)->GetOwningTableView();
if (!owner || winrt::get_self<TableView>(owner) != this)
{
return false;
}
uint32_t index{};
auto const columns = owner.Columns();
return columns && columns.IndexOf(column, index);
}
bool TableView::IsContextMenuTargetCurrent(
TableViewDetails::ContextMenuTarget const& target) const
{
using TableViewDetails::ContextMenuTargetKind;
if (!target.Anchor || !target.ScopeRoot || !target.Anchor.IsLoaded())
{
return false;
}
auto node = target.ScopeRoot.as<winrt::DependencyObject>();
winrt::TableView owner{ nullptr };
while (node && !owner)
{
owner = node.try_as<winrt::TableView>();
if (!owner)
{
node = winrt::VisualTreeHelper::GetParent(node);
}
}
if (!owner || winrt::get_self<TableView>(owner) != this)
{
return false;
}
if (target.Column && !IsContextMenuColumn(target.Column))
{
return false;
}
if (target.Kind == ContextMenuTargetKind::Body)
{
if (!target.Row || winrt::get_self<TableViewRow>(target.Row)->GetOwningTableView() != owner)
{
return false;
}
return winrt::get_self<TableViewRow>(target.Row)->IsContextMenuTargetCurrent(target);
}
if (target.Kind != ContextMenuTargetKind::Header)
{
return false;
}
auto const showsColumnHeaders =
(static_cast<uint32_t>(owner.HeadersVisibility()) & static_cast<uint32_t>(winrt::TableViewHeadersVisibility::Column)) != 0;
if (target.Row || target.Item || !target.Column ||
target.ScopeRoot != m_headerHost.get() ||
!showsColumnHeaders)
{
return false;
}
return TableViewDetails::IsLiveCellAnchor(target.Anchor, target.ScopeRoot, target.Column);
}
bool TableViewDetails::IsLiveCellAnchor(
winrt::FrameworkElement const& anchor,
winrt::Panel const& scopeRoot,
winrt::TableViewColumn const& column)
{
return anchor && scopeRoot && column &&
anchor.Visibility() == winrt::Visibility::Visible &&
winrt::VisualTreeHelper::GetParent(anchor) == scopeRoot &&
TableViewCellsPanel::CellForColumn(scopeRoot, column) == anchor;
}
bool TableViewDetails::IsOwnRequestSource(
winrt::DependencyObject const& source,
winrt::DependencyObject const& scope)
{
for (auto node = source; node; node = winrt::VisualTreeHelper::GetParent(node))
{
if (node == scope)
{
return true;
}
if (node.try_as<winrt::TableView>() || node.try_as<winrt::TableViewRow>())
{
return false;
}
}
return false;
}
std::optional<TableViewDetails::ContextMenuTarget> TableView::ResolveHeaderContextMenuTarget(
winrt::ContextRequestedEventArgs const& args)
{
auto const host = m_headerHost.get();
auto const source = args.OriginalSource().try_as<winrt::DependencyObject>();
if (!host || !TableViewDetails::IsOwnRequestSource(source, host))
{
return std::nullopt;
}
auto node = source;
while (node && node != host)
{
auto const parent = winrt::VisualTreeHelper::GetParent(node);
if (parent == host)
{
auto const cell = node.try_as<winrt::FrameworkElement>();
auto const column = cell ? TableViewCellsPanel::ColumnForCell(cell) : nullptr;
if (!column)
{
return std::nullopt;
}
TableViewDetails::ContextMenuTarget target;
target.Kind = TableViewDetails::ContextMenuTargetKind::Header;
target.Column = column;
target.Anchor = cell;
target.ScopeRoot = host;
if (!IsContextMenuTargetCurrent(target))
{
return std::nullopt;
}
return target;
}
node = parent;
}
return std::nullopt;
}
void TableView::SuppressContextRequestWhileEditing(winrt::ContextRequestedEventArgs const& args)
{
if (!args.Handled() && AreContextMenusSuppressed())
{
args.Handled(true);
}
}
void TableView::OnHeaderContextRequested(winrt::ContextRequestedEventArgs const& args)
{
if (args.Handled())
{
return;
}
auto target = ResolveHeaderContextMenuTarget(args);
if (!target)
{
return;
}
if (ProcessContextMenuRequest(*target, args))
{
args.Handled(true);
}
}
namespace
{
bool IsTextControl(winrt::DependencyObject const& element)
{
return element.try_as<winrt::TextBlock>() || element.try_as<winrt::RichTextBlock>() ||
element.try_as<winrt::RichTextBlockOverflow>() || element.try_as<winrt::TextBox>() ||
element.try_as<winrt::RichEditBox>() || element.try_as<winrt::PasswordBox>();
}
bool HasAppContextFlyout(winrt::UIElement const& element)
{
if (!element.ContextFlyout())
{
return false;
}
// Text controls carry a built-in copy/select flyout as their default value; only a flyout the
// app set on the element itself counts. A Style-set flyout on a text control is therefore
// skipped here (documented on TableViewColumn.CellContextFlyout).
return !IsTextControl(element) ||
element.ReadLocalValue(winrt::UIElement::ContextFlyoutProperty()) != winrt::DependencyProperty::UnsetValue();
}
// The row's own XAML menu: the first app ContextFlyout from just above the cell (or the row
// itself, for a row target) up to and including the row. Covers a row style setter and row
// template elements, anchored on the element that owns it, as the framework would show it.
TableViewDetails::ContextMenuCandidate FindRowContextFlyout(TableViewDetails::ContextMenuTarget const& target)
{
if (!target.Row)
{
return {};
}
winrt::DependencyObject const row = target.Row;
auto node = target.Column && target.Anchor
? winrt::VisualTreeHelper::GetParent(target.Anchor)
: row;
while (node)
{
if (auto const element = node.try_as<winrt::FrameworkElement>(); element && HasAppContextFlyout(element))
{
return { element.ContextFlyout(), element };
}
if (node == row)
{
break;
}
node = winrt::VisualTreeHelper::GetParent(node);
}
return {};
}
// First app-supplied ContextFlyout inside a cell or header wrapper (CellTemplate, HeaderTemplate,
// generated element). Text controls' built-in flyouts are ignored.
TableViewDetails::ContextMenuCandidate FindContentContextFlyout(winrt::FrameworkElement const& wrapper)
{
if (!wrapper)
{
return {};
}
// Depth-first in visual order so the outermost element wins. A nested items host's own
// ContextFlyout counts, its items' do not; a nested TableView is skipped entirely.
std::vector<winrt::DependencyObject> stack;
auto const pushChildren = [&stack](winrt::DependencyObject const& parent)
{
auto const count = winrt::VisualTreeHelper::GetChildrenCount(parent);
for (int32_t i = count - 1; i >= 0; --i)
{
stack.push_back(winrt::VisualTreeHelper::GetChild(parent, i));
}
};
pushChildren(wrapper);
while (!stack.empty())
{
auto const node = std::move(stack.back());
stack.pop_back();
auto const element = node.try_as<winrt::UIElement>();
if (!element || element.Visibility() != winrt::Visibility::Visible ||
node.try_as<winrt::TableView>())
{
continue;
}
if (HasAppContextFlyout(element))
{
if (auto const anchor = element.try_as<winrt::FrameworkElement>())
{
return { element.ContextFlyout(), anchor };
}
}
if (node.try_as<winrt::ItemsControl>() || node.try_as<winrt::ItemsRepeater>())
{
continue;
}
pushChildren(node);
}
return {};
}
}
TableViewDetails::ContextMenuCandidate TableView::ResolveContextFlyout(
TableViewDetails::ContextMenuTarget const& target)
{
// A ContextFlyout the app placed inside the cell or header (template or style) wins over the
// column/row properties, so keyboard and wrapper-padding requests match what a pointer request
// on the content already shows natively.
if (target.Column)
{
if (auto content = FindContentContextFlyout(target.Anchor))
{
return content;
}
}
if (target.Kind == TableViewDetails::ContextMenuTargetKind::Header)
{
return { target.Column.HeaderContextFlyout(), target.Anchor };
}
if (target.Column)
{
if (auto const flyout = target.Column.CellContextFlyout())
{
return { flyout, target.Anchor };
}
}
// Handling the request here stops the framework from showing the row's own ContextFlyout, so
// resolve it at the same place it would have won natively: after the cell's own menus. With
// nothing found the request stays Unhandled and bubbles to the table's ContextFlyout.
return FindRowContextFlyout(target);
}
void TableView::ShowContextFlyout(
TableViewDetails::ContextMenuTarget const& target,
TableViewDetails::ContextMenuCandidate const& candidate,
winrt::ContextRequestedEventArgs const& args)
{
winrt::FlyoutShowOptions options;
options.Placement(winrt::FlyoutPlacementMode::Auto);
winrt::Point point{};
if (args && args.TryGetPosition(candidate.Anchor, point))
{
options.Position(point);
}
else if (target.Column && target.Anchor && candidate.Anchor != target.Anchor)
{
// A keyboard or automation request has no pointer position. A menu owned by a larger
// element (the row) still opens at the focused cell, not across the whole row; the owner
// stays the flyout's Target.
auto const cell = target.Anchor;
auto const bounds = cell.TransformToVisual(candidate.Anchor).TransformBounds(
{ 0.0f, 0.0f, static_cast<float>(cell.ActualWidth()), static_cast<float>(cell.ActualHeight()) });
options.ExclusionRect(bounds);
options.Position(winrt::Point{ bounds.X, bounds.Y + bounds.Height });
}
candidate.Flyout.ShowAt(candidate.Anchor, options);
}
bool TableView::IsContextMenuRequestCurrent(
TableViewDetails::ContextMenuTarget const& target,
winrt::IInspectable const& sourceSnapshot)
{
return IsContextMenuTargetCurrent(target) &&
(target.Kind != TableViewDetails::ContextMenuTargetKind::Body ||
(ItemsSource() == sourceSnapshot && !AreContextMenusSuppressed()));
}
bool TableView::FocusContextMenuTarget(
TableViewDetails::ContextMenuTarget const& target,
winrt::IInspectable const& sourceSnapshot)
{
auto const focusedElement = [this]() -> winrt::DependencyObject
{
auto const root = XamlRoot();
return root
? winrt::FocusManager::GetFocusedElement(root).try_as<winrt::DependencyObject>()
: nullptr;
};
auto const isFocusWithin = [](winrt::DependencyObject const& focused, winrt::DependencyObject const& element)
{
return focused &&
(focused == element || SharedHelpers::IsAncestor(focused, element, false /* checkVisibility */));
};
// Focus the exact target (cell, row or header) so focus restore and Narrator use it. Body
// targets go through the two-level focus helpers so the cursor level follows.
if (target.Kind == TableViewDetails::ContextMenuTargetKind::Header)
{
if (!isFocusWithin(focusedElement(), target.Anchor))
{
// Header cells are tab stops; a request on the header's content keeps focus where it is.
target.Anchor.Focus(winrt::FocusState::Programmatic);
}
return true;
}
if (target.Column)
{
SetCurrentCell(target.Item, target.Column);
}
if (!IsContextMenuRequestCurrent(target, sourceSnapshot))
{
return false;
}
winrt::DependencyObject const rowObject = target.Row;
auto focused = focusedElement();
if (!target.Column)
{
if (focused != rowObject)
{
FocusRowContainerInternal(target.Row, winrt::FocusState::Programmatic);
}
return true;
}
// Entering the row from outside lands on the row first. App code reacting to that focus
// change (removing the item, say) recycles the row back to row level, which would leave a
// direct cell focus aimed at a cell that is no longer focusable.
if (!isFocusWithin(focused, rowObject))
{
FocusRowContainerInternal(target.Row, winrt::FocusState::Programmatic);
if (!IsContextMenuRequestCurrent(target, sourceSnapshot))
{
return false;
}
focused = focusedElement();
}
if (!isFocusWithin(focused, target.Anchor))
{
auto const rowImpl = winrt::get_self<TableViewRow>(target.Row);
if (int32_t const column = rowImpl->GetVisibleCellIndexInternal(target.Anchor); column >= 0)
{
FocusRealizedRowCell(target.Row, column, winrt::FocusState::Programmatic);
}
}
return true;
}
bool TableView::ProcessContextMenuRequest(
TableViewDetails::ContextMenuTarget const& target,
winrt::ContextRequestedEventArgs const& args)
{
// args is null for a row automation peer request, which has no routed event.
if (args && args.Handled())
{
return false;
}
// Defensive: the framework fail-fasts a synchronous nested ShowContextMenu before it reaches
// here, so this only guards against a future path that re-enters while focus or menu app code runs.
if (m_isProcessingContextMenu)
{
return true;
}
// No TableView menu shows while a cell is editing: the request is handled with nothing shown,
// so the row and table menus cannot open through the native chain either. An editor with its
// own menu (a TextBox) has already shown it and handled the request.
if (AreContextMenusSuppressed())
{
return true;
}
if (!IsContextMenuTargetCurrent(target))
{
return true;
}
// Moving focus runs app code. If that makes the target stale, the request stays handled only
// when the TableView would have shown a menu, so it cannot fall through to the native chain
// at a different element.
bool const staleResult = static_cast<bool>(ResolveContextFlyout(target));
auto const lifetime = get_strong();
m_isProcessingContextMenu = true;
auto restore = wil::scope_exit([this]() noexcept
{
m_isProcessingContextMenu = false;
});
auto const sourceSnapshot = ItemsSource();
if (!FocusContextMenuTarget(target, sourceSnapshot) || !IsContextMenuRequestCurrent(target, sourceSnapshot))
{
return staleResult;
}
// Resolved again once focus has settled: app code run by the focus move may have replaced a
// column's menu or the cell's content, and the menu shown is the one in place now.
auto const candidate = ResolveContextFlyout(target);
if (!candidate)
{
return false;
}
ShowContextFlyout(target, candidate, args);
return true;
}