diff --git a/CONTEXT.md b/CONTEXT.md
index f2aa2cc7c5..b986a7f170 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -67,6 +67,7 @@ It is intentionally not a full architecture manual. It should stay biased toward
- **Utility**: A user-invoked data maintenance or migration tool (e.g. resetting homographs, removing parser annotations, fixing duplicate analyses). Utilities implement `IUtility` (`FwCoreDlgs`), are registered in `UtilityCatalogInclude.xml` via reflection, and run through `UtilityDlg` (Tools > Utilities menu).
- **DistFiles**: Runtime assets copied into outputs or installers.
- **Detail**: In the Avalonia migration, the framework-neutral model of an editing view: a flattened, ordered list of typed fields (`DetailModel` / `DetailField`) composed from the view definition and rendered by the Avalonia `DataTree`. A detail model is data; the rendered view is its visible form. It supersedes **Region**, the migration-invented word this vocabulary replaced — do not reintroduce `Region*` names for these concepts.
+- **CurrentRow**: Reserved name for a future focus-tracking property/event on the Avalonia `DataTree` (`Src/Common/FwAvalonia/Detail/DataTree.cs`), analogous to the Winforms `Slice`'s `CurrentSlice`/`CurrentSliceChanged`; named for the rendered unit that would hold focus (a **row**) rather than the model vocabulary (`DetailField`) it is built from. Not currently implemented. Use this name, not `CurrentField` or another alternative, whenever a first real consumer needs it built.
- **View definition**: The typed IR (`ViewDefinitionModel`, a `ViewNode` tree) compiled from the legacy XML Parts/Layout files by `ViewDefinitionCompiler` and cached by `(className, layoutName, layoutType, fingerprint)`. It is the input the composer projects into a `DetailModel`.
- **Surface**: RETIRED as project vocabulary — it accumulated conflicting scopes (record UIs only, record UIs plus dialogs, any migratable UI). Say the concrete thing: a **view** (the rendered UI a tool shows for a record — the legacy WinForms `DataTree`/`BrowseViewer` or the Avalonia `DetailHostControl`), a **dialog**, or the **UI framework** (`UIFramework`, selected per tool from the `UIMode` setting via `UIFrameworkResolver` / `UIFrameworkRegistry`). "Surface" survives only in styling vocabulary (the drawn background: `FwSurfaceStyles`, the surface font) and in the linguistic term "surface form".
- **Cross-namespace twin**: A deliberate reuse of a legacy type name for its Avalonia counterpart (`FwAvalonia.Detail.DataTree` beside `DetailControls.DataTree`; `SliceFactory`, `MSAGroupBox`). Disambiguate with a `using` alias at the call site; never rename the type to dodge the collision. The twins cover only the half of the legacy responsibility that still fits — the Avalonia `DataTree` renders a prebuilt `DetailModel` and does not compose it.
diff --git a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs
index 2f5a807c0b..3ed7a73fb1 100644
--- a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs
+++ b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs
@@ -84,7 +84,8 @@ protected void SetHostContent(Avalonia.Controls.Control content)
}
/// The current Avalonia content, or null.
- protected Avalonia.Controls.Control CurrentContent => Host.Content as Avalonia.Controls.Control;
+ protected Avalonia.Controls.Control CurrentContent =>
+ Host.Content as Avalonia.Controls.Control;
public void SetCompanionControls(IReadOnlyList controls)
{
diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs
index ffd956daa2..2437d1a60a 100644
--- a/Src/Common/FwAvalonia/Detail/DataTree.cs
+++ b/Src/Common/FwAvalonia/Detail/DataTree.cs
@@ -1,4 +1,4 @@
-// Copyright (c) 2026 SIL International
+// Copyright (c) 2026 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)
@@ -10,6 +10,7 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
+using Avalonia.VisualTree;
using Avalonia.Styling;
using SIL.FieldWorks.Common.FwAvalonia;
using SIL.FieldWorks.Common.FwAvalonia.Seams;
@@ -165,6 +166,10 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null,
Width = FwAvaloniaDensity.SplitterWidth
};
AutomationProperties.SetAutomationId(splitter, "DataTree.Splitter");
+ // Chrome, not a field: a column-resize handle has no row of its own for a
+ // TabIndex, so it defaults to Avalonia's int.MaxValue and sorts out of
+ // order. Excluded from the tab order entirely (LT-22688).
+ Avalonia.Input.KeyboardNavigation.SetIsTabStop(splitter, false);
Grid.SetColumn(splitter, 1);
outerGrid.Children.Add(splitter); // added after the Form so its drag handle stays hit-testable
outerGrid.LayoutUpdated += (s, e) =>
@@ -202,6 +207,11 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null,
AutomationProperties.SetAutomationId(scroller, "DataTree.Scroll");
Content = scroller;
+ // Contains Tab/Shift+Tab to this view instead of continuing into the hosting
+ // WinForms Form (LT-22688).
+ Avalonia.Input.KeyboardNavigation.SetTabNavigation(this,
+ Avalonia.Input.KeyboardNavigationMode.Contained);
+
// Screen-local command shortcuts:
// Enter commits (validation-gated), Escape cancels -- handled at the view so they
// work
@@ -227,6 +237,10 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null,
// commit) before the held re-show is released.
AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, (s, e) => EndPointerGesture(),
Avalonia.Interactivity.RoutingStrategies.Bubble, handledEventsToo: true);
+
+ // Scrolls newly focused rows into view (LT-22688).
+ AddHandler(Avalonia.Input.InputElement.GotFocusEvent, OnRowGotFocus,
+ Avalonia.Interactivity.RoutingStrategies.Bubble);
}
// A press that takes no capture, released after the pointer leaves the view, bubbles
@@ -294,6 +308,30 @@ private void OnViewKeyDown(object sender, Avalonia.Input.KeyEventArgs e)
}
}
+ // Scrolls newly focused rows into view (LT-22688).
+ private void OnRowGotFocus(object sender, Avalonia.Input.GotFocusEventArgs e)
+ {
+ (e.Source as Control)?.BringIntoView();
+ }
+
+ ///
+ /// Applies as the TabIndex of and every
+ /// visual descendant, not just itself -- TabIndex does not
+ /// inherit in Avalonia, so a composite control's real focusable parts (a header's
+ /// hotlink button, a multi-writing-system field's per-value text box, ...) would
+ /// otherwise keep Avalonia's default TabIndex (int.MaxValue) and sort after every
+ /// explicitly row-indexed control regardless of visual position (LT-22688).
+ ///
+ private static void ApplyRowTabIndex(Control root, int row)
+ {
+ Avalonia.Input.KeyboardNavigation.SetTabIndex(root, row);
+ foreach (var descendant in root.GetVisualDescendants())
+ {
+ if (descendant is Control descendantControl)
+ Avalonia.Input.KeyboardNavigation.SetTabIndex(descendantControl, row);
+ }
+ }
+
/// The detail model this view renders.
public DetailModel Model { get; }
@@ -490,7 +528,7 @@ private struct FieldContent
public Control Label;
}
- private FieldContent AddField(int index, DetailField field)
+ private FieldContent AddField(int row, DetailField field)
{
var automationId = string.IsNullOrEmpty(field.AutomationId) ? field.StableId : field.AutomationId;
var indent = new Thickness(field.Indent * 12, 0, 0, 0);
@@ -530,6 +568,8 @@ private FieldContent AddField(int index, DetailField field)
RebuildItems();
};
header = button;
+ // Chrome, not a field, same reasoning as the field-menu kebab (LT-22688).
+ Avalonia.Input.KeyboardNavigation.SetIsTabStop(button, false);
}
else
{
@@ -563,7 +603,7 @@ private FieldContent AddField(int index, DetailField field)
// The header cell and its inline hotlink strip always travel together (the strip is part of
// the header row, hidden/shown with it by the collapse logic).
Control headerControl;
- if (field.Indent == 0 && index > 0)
+ if (field.Indent == 0 && row > 0)
{
var withRule = new StackPanel();
withRule.Children.Add(new Border
@@ -591,6 +631,9 @@ private FieldContent AddField(int index, DetailField field)
if (headerKebab != null)
HoverReveal.Attach(new[] { headerCell }, new[] { headerKebab });
+ // The hotlink strip's "Field Options" button gets no TabIndex any other
+ // way, so it would default to int.MaxValue and sort dead last (LT-22688).
+ ApplyRowTabIndex(headerControl, row);
return new FieldContent { Content = headerControl, Label = null };
}
@@ -626,6 +669,11 @@ private FieldContent AddField(int index, DetailField field)
ToolTip.SetTip(labelBlock, field.Label ?? field.Field); // 11.17: legacy label tooltips
var editor = CreateEditor(field, automationId);
editor.Margin = new Thickness(0, 0, 0, FwAvaloniaDensity.FieldSpacing);
+
+ // Every reachable control in a row shares its TabIndex, so Avalonia visits them in
+ // visual order (editor first, then any affordance below) before moving to the next
+ // row (LT-22688).
+ ApplyRowTabIndex(editor, row);
if (editor is FwReferenceVectorField vector)
{
_vectors.Add(vector);
@@ -644,7 +692,13 @@ private FieldContent AddField(int index, DetailField field)
if (labelKebab != null)
HoverReveal.Attach(hoverSources, new[] { labelKebab });
if (editor is IHoverAffordanceProvider provider && provider.HoverAffordances.Count > 0)
+ {
HoverReveal.Attach(hoverSources, provider.HoverAffordances);
+ // Sequences a row's own affordances (the chooser's configure gear, a
+ // reference vector's add/gear buttons) right after its editor (LT-22688).
+ foreach (var affordance in provider.HoverAffordances)
+ Avalonia.Input.KeyboardNavigation.SetTabIndex(affordance, row);
+ }
return new FieldContent { Content = editor, Label = labelCell };
}
@@ -696,6 +750,8 @@ private Control WrapWithFieldMenu(Control inner, DetailField field, string autom
if (hasMenu || hasHotlinks)
{
var button = DetailChrome.CreateKebabButton();
+ // Mouse/right-click reachable only; never its own Tab stop (LT-22688).
+ Avalonia.Input.KeyboardNavigation.SetIsTabStop(button, false);
AutomationProperties.SetAutomationId(button, automationId + ".FieldMenu");
AutomationProperties.SetName(button, FwAvaloniaStrings.FieldOptionsMenu);
ToolTip.SetTip(button, FwAvaloniaStrings.FieldOptionsMenu);
diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs
index ae7f894921..f45808e0fc 100644
--- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs
+++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs
@@ -948,6 +948,17 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat
};
display.AddHandler(InputElement.PointerPressedEvent, displayPressed,
Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ // Makes `display` an ordinary tab stop; GotFocus swaps in the editable
+ // `box` the same way the pointer-press handler above does, matching a
+ // mouse click's entry point (LT-22688).
+ display.Focusable = true;
+ EventHandler displayGotFocus = (s, e) =>
+ {
+ box.IsVisible = true;
+ display.IsVisible = false;
+ box.Focus();
+ };
+ display.GotFocus += displayGotFocus;
EventHandler lost = (s, e) =>
{
box.IsVisible = false;
@@ -957,6 +968,7 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat
_teardown.Add(() =>
{
display.RemoveHandler(InputElement.PointerPressedEvent, displayPressed);
+ display.GotFocus -= displayGotFocus;
box.LostFocus -= lost;
});
}
diff --git a/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs b/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs
index b2014c3cee..9fa1a9f1c2 100644
--- a/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs
+++ b/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs
@@ -387,6 +387,17 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat
};
display.AddHandler(InputElement.PointerPressedEvent, displayPressed,
Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ // Makes `display` an ordinary tab stop; GotFocus swaps in the editable
+ // `box` the same way the pointer-press handler above does, matching a
+ // mouse click's entry point (LT-22688).
+ display.Focusable = true;
+ EventHandler displayGotFocus = (s, e) =>
+ {
+ box.IsVisible = true;
+ display.IsVisible = false;
+ box.Focus();
+ };
+ display.GotFocus += displayGotFocus;
EventHandler lost = (s, e) =>
{
box.IsVisible = false;
@@ -396,6 +407,7 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat
_teardown.Add(() =>
{
display.RemoveHandler(InputElement.PointerPressedEvent, displayPressed);
+ display.GotFocus -= displayGotFocus;
box.LostFocus -= lost;
});
}
@@ -537,7 +549,11 @@ private Button CreateIconButton(string automationId, string glyph, string access
Background = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
Foreground = FwAvaloniaDensity.WsAbbrevBrush,
- VerticalAlignment = VerticalAlignment.Top
+ VerticalAlignment = VerticalAlignment.Top,
+ // The trigger must NOT take focus, matching every other per-row picker/style
+ // button in this file: Tab reaches the paragraph editor directly instead of
+ // stopping on Add/Delete first (LT-22688).
+ Focusable = false
};
AutomationProperties.SetAutomationId(button, automationId);
AutomationProperties.SetName(button, accessibleName);
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/DataTreeTabNavigationIntegrationTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/DataTreeTabNavigationIntegrationTests.cs
new file mode 100644
index 0000000000..ef3d279fde
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/DataTreeTabNavigationIntegrationTests.cs
@@ -0,0 +1,320 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
+using FwAvaloniaTests.VisualChecks; // DialogSnapshot -- the PNG harness
+
+namespace FwAvaloniaTests.Detail
+{
+ ///
+ /// Headless proof for LT-22688's DataTree Tab/Shift+Tab row navigation, one test per
+ /// navigation scenario: tab order, row-boundary containment, collapsed-row skipping, kebab
+ /// exclusion, scroll-into-view, native focus state, multi-writing-system rows, and rich
+ /// structured-text paragraphs. Every test drives Tab/Shift+Tab through the real headless
+ /// input pipeline (never by calling internal handlers directly), so a regression in
+ /// Avalonia's own tab-walk or in the host's key-claiming would fail these the same way a
+ /// live user would notice it.
+ ///
+ [TestFixture]
+ public class DataTreeTabNavigationIntegrationTests
+ {
+ private static DetailField Field(string id, DetailFieldKind kind = DetailFieldKind.Text,
+ int indent = 0, string menuId = null, bool isCollapsible = false,
+ bool isInitiallyExpanded = true, IReadOnlyList values = null)
+ => new DetailField(id, id, id, null, kind,
+ EditorClassification.Known, id, null, HostRouting.Inherit,
+ kind == DetailFieldKind.Text
+ ? values ?? new List { new DetailWsValue("vern", "value") }
+ : null,
+ null, null,
+ isEditable: kind == DetailFieldKind.Text, indent: indent,
+ isCollapsible: isCollapsible, isInitiallyExpanded: isInitiallyExpanded,
+ menuId: menuId, objectHvo: 1234);
+
+ // A non-null menuRequested is what makes WrapWithFieldMenu render a field's kebab at
+ // all (a null host bridge means no menu button to test); the request content itself is
+ // irrelevant here.
+ private static (Window Window, DataTree View) Show(double width, double height,
+ params DetailField[] fields)
+ {
+ var model = new DetailModel("LexEntry", "Normal", fields.ToList(), new List());
+ var view = new DataTree(model, menuRequested: request => { });
+ var window = new Window { Content = view, Width = width, Height = height };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ return (window, view);
+ }
+
+ // Same as Show(), but threads a real edit context through -- a structured-text row
+ // only wires up paragraph editing, and so the focus-swap, with one (LT-22688).
+ private static (Window Window, DataTree View) ShowWithEditContext(double width, double height,
+ IDetailEditContext editContext, params DetailField[] fields)
+ {
+ var model = new DetailModel("LexEntry", "Normal", fields.ToList(), new List());
+ var view = new DataTree(model, editContext: editContext, menuRequested: request => { });
+ var window = new Window { Content = view, Width = width, Height = height };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ return (window, view);
+ }
+
+ private static T Find(Visual root, string automationId) where T : Visual
+ => root.GetVisualDescendants().OfType()
+ .First(c => AutomationProperties.GetAutomationId(c) == automationId);
+
+ private static Control FindFocused(Visual root)
+ => root.GetVisualDescendants().OfType().FirstOrDefault(c => c.IsFocused);
+
+ private static string FocusedAutomationId(Visual root)
+ {
+ var focused = FindFocused(root);
+ return focused == null ? null : AutomationProperties.GetAutomationId(focused);
+ }
+
+ private static void Tab(Window window)
+ {
+ window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ private static void ShiftTab(Window window)
+ {
+ window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.Shift);
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ [AvaloniaTest]
+ public void TabOrderForward_VisitsEveryRowInModelOrder_NeverTheKebab()
+ {
+ var (window, view) = Show(480, 300,
+ Field("Row0"),
+ Field("Row1", menuId: "mnuDataTree-Help"),
+ Field("Row2"),
+ Field("Row3"));
+
+ Find(view, "Row0.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+ Assert.That(FocusedAutomationId(view), Is.EqualTo("Row0.vern"));
+
+ var visited = new List();
+ for (var i = 0; i < 3; i++)
+ {
+ Tab(window);
+ visited.Add(FocusedAutomationId(view));
+ }
+
+ Assert.That(visited, Is.EqualTo(new[] { "Row1.vern", "Row2.vern", "Row3.vern" }),
+ "Tab visits every row once, in model order, and never the kebab");
+ DialogSnapshot.Capture(window, "DataTree-TabNavigation-01-tab-order-forward");
+ }
+
+ [AvaloniaTest]
+ public void TabOrderReverse_MirrorsForwardExactly()
+ {
+ var (window, view) = Show(480, 300,
+ Field("Row0"),
+ Field("Row1", menuId: "mnuDataTree-Help"),
+ Field("Row2"),
+ Field("Row3"));
+
+ Find(view, "Row3.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+ Assert.That(FocusedAutomationId(view), Is.EqualTo("Row3.vern"));
+
+ var visited = new List();
+ for (var i = 0; i < 3; i++)
+ {
+ ShiftTab(window);
+ visited.Add(FocusedAutomationId(view));
+ }
+
+ Assert.That(visited, Is.EqualTo(new[] { "Row2.vern", "Row1.vern", "Row0.vern" }),
+ "Shift+Tab retraces the forward order exactly in reverse");
+ DialogSnapshot.Capture(window, "DataTree-TabNavigation-02-tab-order-reverse");
+ }
+
+ [AvaloniaTest]
+ public void ShiftTabAtFirstRow_StaysContained()
+ {
+ var (window, view) = Show(480, 300, Field("Row0"), Field("Row1"));
+
+ Find(view, "Row0.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ ShiftTab(window);
+
+ Assert.That(FocusedAutomationId(view), Is.EqualTo("Row0.vern"),
+ "Contained navigation keeps Shift+Tab from leaving the view at the first row");
+ DialogSnapshot.Capture(window, "DataTree-TabNavigation-03-first-row-contained");
+ }
+
+ [AvaloniaTest]
+ public void TabAtLastRow_StaysContained()
+ {
+ var (window, view) = Show(480, 300, Field("Row0"), Field("Row1"));
+
+ Find(view, "Row1.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ Tab(window);
+
+ Assert.That(FocusedAutomationId(view), Is.EqualTo("Row1.vern"),
+ "Contained navigation keeps Tab from leaving the view at the last row");
+ DialogSnapshot.Capture(window, "DataTree-TabNavigation-04-last-row-contained");
+ }
+
+ [AvaloniaTest]
+ public void Tab_SkipsRowsOwnedByACollapsedHeader()
+ {
+ var (window, view) = Show(480, 300,
+ Field("Before"),
+ Field("Section", kind: DetailFieldKind.Header, isCollapsible: true,
+ isInitiallyExpanded: false),
+ Field("Inner0", indent: 1),
+ Field("Inner1", indent: 1),
+ Field("After"));
+
+ Find(view, "Before.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ Tab(window);
+
+ Assert.That(FocusedAutomationId(view), Is.EqualTo("After.vern"),
+ "a collapsed section's own header and every row it owns are unreachable by Tab");
+ DialogSnapshot.Capture(window, "DataTree-TabNavigation-05-skips-collapsed-rows");
+ }
+
+ [AvaloniaTest]
+ public void FieldMenuKebab_IsNeverATabStop()
+ {
+ var (window, view) = Show(480, 300, Field("Row0", menuId: "mnuDataTree-Help"));
+
+ var kebab = Find