using System;
using UnityEngine.UIElements;
namespace Uigenx
{
///
/// Keeps pointer and navigation focus inside a modal. UI Toolkit 6000.5 has no focus scope API,
/// so the trap is assembled from the parts that do exist: a full screen blocker that swallows
/// picking, and focus and navigation handlers on the layer root that pull focus back in.
///
public sealed class UgxModalFocusTrap : IDisposable
{
public const string BlockerName = "ugx-modal-blocker";
public const string BlockerUssClassName = "ugx-modal-blocker";
readonly VisualElement _layerRoot;
readonly VisualElement _modalRoot;
readonly VisualElement _blocker;
readonly Focusable _previouslyFocused;
readonly bool _previousDelegatesFocus;
EventCallback _onFocusIn;
EventCallback _onNavigationMove;
bool _disposed;
UgxModalFocusTrap(VisualElement layerRoot, VisualElement modalRoot, Focusable previouslyFocused)
{
_layerRoot = layerRoot;
_modalRoot = modalRoot;
_previouslyFocused = previouslyFocused;
_previousDelegatesFocus = modalRoot.delegatesFocus;
_blocker = new VisualElement { name = BlockerName, pickingMode = PickingMode.Position, focusable = false };
_blocker.AddToClassList(BlockerUssClassName);
_blocker.style.position = Position.Absolute;
_blocker.style.left = 0f;
_blocker.style.top = 0f;
_blocker.style.right = 0f;
_blocker.style.bottom = 0f;
}
public VisualElement Blocker => _blocker;
public VisualElement ModalRoot => _modalRoot;
public static UgxModalFocusTrap Install(VisualElement layerRoot, VisualElement modalRoot)
{
if (layerRoot == null) throw new ArgumentNullException(nameof(layerRoot));
if (modalRoot == null) throw new ArgumentNullException(nameof(modalRoot));
var trap = new UgxModalFocusTrap(layerRoot, modalRoot, layerRoot.focusController?.focusedElement);
trap.Attach();
return trap;
}
void Attach()
{
int index = _layerRoot.IndexOf(_modalRoot);
if (index < 0) _layerRoot.Add(_blocker);
else _layerRoot.Insert(index, _blocker);
_modalRoot.delegatesFocus = true;
_onFocusIn = OnFocusIn;
_onNavigationMove = OnNavigationMove;
_layerRoot.RegisterCallback(_onFocusIn, TrickleDown.TrickleDown);
_layerRoot.RegisterCallback(_onNavigationMove, TrickleDown.TrickleDown);
_modalRoot.schedule.Execute(FocusFirst).StartingIn(0);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_layerRoot.UnregisterCallback(_onFocusIn, TrickleDown.TrickleDown);
_layerRoot.UnregisterCallback(_onNavigationMove, TrickleDown.TrickleDown);
_onFocusIn = null;
_onNavigationMove = null;
_blocker.RemoveFromHierarchy();
_modalRoot.delegatesFocus = _previousDelegatesFocus;
if (_previouslyFocused != null && _previouslyFocused.canGrabFocus) _previouslyFocused.Focus();
}
void OnFocusIn(FocusInEvent evt)
{
if (_disposed) return;
if (IsInsideModal(evt.target as VisualElement)) return;
// Focus already moved, so pulling it back has to happen after this event resolves.
_modalRoot.schedule.Execute(FocusFirst).StartingIn(0);
}
void OnNavigationMove(NavigationMoveEvent evt)
{
if (_disposed) return;
if (IsInsideModal(_layerRoot.focusController?.focusedElement as VisualElement)) return;
evt.StopPropagation();
FocusFirst();
}
bool IsInsideModal(VisualElement element)
{
for (var current = element; current != null; current = current.parent)
if (ReferenceEquals(current, _modalRoot)) return true;
return false;
}
void FocusFirst()
{
if (_disposed) return;
var first = FindFocusable(_modalRoot);
if (first != null) first.Focus();
else if (_modalRoot.canGrabFocus) _modalRoot.Focus();
}
static VisualElement FindFocusable(VisualElement root)
{
if (root.canGrabFocus && root.tabIndex >= 0 && root.enabledInHierarchy) return root;
for (int i = 0; i < root.childCount; i++)
{
var found = FindFocusable(root[i]);
if (found != null) return found;
}
return null;
}
}
}