mirror of
https://github.com/microsoft/microsoft-ui-xaml.git
synced 2026-10-28 16:35:19 +08:00
## 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
502 lines
16 KiB
C++
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;
|
|
}
|