diff --git a/samples/ControlCatalog/Pages/TextBoxPage.xaml b/samples/ControlCatalog/Pages/TextBoxPage.xaml
index 1ac447ea693..f631c40eb1b 100644
--- a/samples/ControlCatalog/Pages/TextBoxPage.xaml
+++ b/samples/ControlCatalog/Pages/TextBoxPage.xaml
@@ -18,6 +18,7 @@
Watermark="Floating Watermark"
UseFloatingWatermark="True"
Text="Lorem ipsum dolor sit amet, consectetur adipiscing elit."/>
+
diff --git a/src/Avalonia.Controls/MaskedTextBox.cs b/src/Avalonia.Controls/MaskedTextBox.cs
new file mode 100644
index 00000000000..a72c617f054
--- /dev/null
+++ b/src/Avalonia.Controls/MaskedTextBox.cs
@@ -0,0 +1,433 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+ public class MaskedTextBox : TextBox, IStyleable
+ {
+ public static readonly StyledProperty AsciiOnlyProperty =
+ AvaloniaProperty.Register(nameof(AsciiOnly));
+
+ public static readonly DirectProperty CultureProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Culture), o => o.Culture,
+ (o, v) => o.Culture = v, CultureInfo.CurrentCulture);
+
+ public static readonly StyledProperty HidePromptOnLeaveProperty =
+ AvaloniaProperty.Register(nameof(HidePromptOnLeave));
+
+ public static readonly DirectProperty MaskCompletedProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MaskCompleted), o => o.MaskCompleted);
+
+ public static readonly DirectProperty MaskFullProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MaskFull), o => o.MaskFull);
+
+ public static readonly StyledProperty MaskProperty =
+ AvaloniaProperty.Register(nameof(Mask), string.Empty);
+
+ public static new readonly StyledProperty PasswordCharProperty =
+ AvaloniaProperty.Register(nameof(PasswordChar), '\0');
+
+ public static readonly StyledProperty PromptCharProperty =
+ AvaloniaProperty.Register(nameof(PromptChar), '_');
+
+ public static readonly DirectProperty ResetOnPromptProperty =
+ AvaloniaProperty.RegisterDirect(nameof(ResetOnPrompt), o => o.ResetOnPrompt, (o, v) => o.ResetOnPrompt = v);
+
+ public static readonly DirectProperty ResetOnSpaceProperty =
+ AvaloniaProperty.RegisterDirect(nameof(ResetOnSpace), o => o.ResetOnSpace, (o, v) => o.ResetOnSpace = v);
+
+ private CultureInfo? _culture;
+
+ private bool _resetOnPrompt = true;
+
+ private bool _ignoreTextChanges;
+
+ private bool _resetOnSpace = true;
+
+ public MaskedTextBox() { }
+
+ ///
+ /// Constructs the MaskedTextBox with the specified MaskedTextProvider object.
+ ///
+ public MaskedTextBox(MaskedTextProvider maskedTextProvider)
+ {
+ if (maskedTextProvider == null)
+ {
+ throw new ArgumentNullException(nameof(maskedTextProvider));
+ }
+ AsciiOnly = maskedTextProvider.AsciiOnly;
+ Culture = maskedTextProvider.Culture;
+ Mask = maskedTextProvider.Mask;
+ PasswordChar = maskedTextProvider.PasswordChar;
+ PromptChar = maskedTextProvider.PromptChar;
+ }
+
+ ///
+ /// Gets or sets a value indicating if the masked text box is restricted to accept only ASCII characters.
+ /// Default value is false.
+ ///
+ public bool AsciiOnly
+ {
+ get => GetValue(AsciiOnlyProperty);
+ set => SetValue(AsciiOnlyProperty, value);
+ }
+
+ ///
+ /// Gets or sets the culture information associated with the masked text box.
+ ///
+ public CultureInfo? Culture
+ {
+ get => _culture;
+ set => SetAndRaise(CultureProperty, ref _culture, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating if the prompt character is hidden when the masked text box loses focus.
+ ///
+ public bool HidePromptOnLeave
+ {
+ get => GetValue(HidePromptOnLeaveProperty);
+ set => SetValue(HidePromptOnLeaveProperty, value);
+ }
+
+ ///
+ /// Gets or sets the mask to apply to the TextBox.
+ ///
+ public string? Mask
+ {
+ get => GetValue(MaskProperty);
+ set => SetValue(MaskProperty, value);
+ }
+
+ ///
+ /// Specifies whether the test string required input positions, as specified by the mask, have
+ /// all been assigned.
+ ///
+ public bool? MaskCompleted
+ {
+ get => MaskProvider?.MaskCompleted;
+ }
+
+ ///
+ /// Specifies whether all inputs (required and optional) have been provided into the mask successfully.
+ ///
+ public bool? MaskFull
+ {
+ get => MaskProvider?.MaskFull;
+ }
+
+ ///
+ /// Gets the MaskTextProvider for the specified Mask.
+ ///
+ public MaskedTextProvider? MaskProvider { get; private set; }
+
+ ///
+ /// Gets or sets the character to be displayed in substitute for user input.
+ ///
+ public new char PasswordChar
+ {
+ get => GetValue(PasswordCharProperty);
+ set => SetValue(PasswordCharProperty, value);
+ }
+
+ ///
+ /// Gets or sets the character used to represent the absence of user input in MaskedTextBox.
+ ///
+ public char PromptChar
+ {
+ get => GetValue(PromptCharProperty);
+ set => SetValue(PromptCharProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating if selected characters should be reset when the prompt character is pressed.
+ ///
+ public bool ResetOnPrompt
+ {
+ get => _resetOnPrompt;
+ set
+ {
+ SetAndRaise(ResetOnPromptProperty, ref _resetOnPrompt, value);
+ if (MaskProvider != null)
+ {
+ MaskProvider.ResetOnPrompt = value;
+ }
+
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating if selected characters should be reset when the space character is pressed.
+ ///
+ public bool ResetOnSpace
+ {
+ get => _resetOnSpace;
+ set
+ {
+ SetAndRaise(ResetOnSpaceProperty, ref _resetOnSpace, value);
+ if (MaskProvider != null)
+ {
+ MaskProvider.ResetOnSpace = value;
+ }
+
+ }
+
+
+ }
+
+ Type IStyleable.StyleKey => typeof(TextBox);
+
+ protected override void OnGotFocus(GotFocusEventArgs e)
+ {
+ if (HidePromptOnLeave == true && MaskProvider != null)
+ {
+ Text = MaskProvider.ToDisplayString();
+ }
+ base.OnGotFocus(e);
+ }
+
+ protected override async void OnKeyDown(KeyEventArgs e)
+ {
+ if (MaskProvider == null)
+ {
+ base.OnKeyDown(e);
+ return;
+ }
+
+ var keymap = AvaloniaLocator.Current.GetService();
+
+ bool Match(List gestures) => gestures.Any(g => g.Matches(e));
+
+ if (Match(keymap.Paste))
+ {
+ var text = await ((IClipboard)AvaloniaLocator.Current.GetService(typeof(IClipboard))).GetTextAsync();
+
+ if (text == null)
+ return;
+
+ foreach (var item in text)
+ {
+ var index = GetNextCharacterPosition(CaretIndex);
+ if (MaskProvider.InsertAt(item, index))
+ {
+ CaretIndex = ++index;
+ }
+ }
+
+ Text = MaskProvider.ToDisplayString();
+ e.Handled = true;
+ return;
+ }
+
+ if (e.Key != Key.Back)
+ {
+ base.OnKeyDown(e);
+ }
+
+ switch (e.Key)
+ {
+ case Key.Delete:
+ if (CaretIndex < Text.Length)
+ {
+ if (MaskProvider.RemoveAt(CaretIndex))
+ {
+ RefreshText(MaskProvider, CaretIndex);
+ }
+
+ e.Handled = true;
+ }
+ break;
+ case Key.Space:
+ if (!MaskProvider.ResetOnSpace || string.IsNullOrEmpty(SelectedText))
+ {
+ if (MaskProvider.InsertAt(" ", CaretIndex))
+ {
+ RefreshText(MaskProvider, CaretIndex);
+ }
+ }
+
+ e.Handled = true;
+ break;
+ case Key.Back:
+ if (CaretIndex > 0)
+ {
+ MaskProvider.RemoveAt(CaretIndex - 1);
+ }
+ RefreshText(MaskProvider, CaretIndex - 1);
+ e.Handled = true;
+ break;
+ }
+ }
+
+ protected override void OnLostFocus(RoutedEventArgs e)
+ {
+ if (HidePromptOnLeave == true && MaskProvider != null)
+ {
+ Text = MaskProvider.ToString(!HidePromptOnLeave, true);
+ }
+ base.OnLostFocus(e);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ void UpdateMaskProvider()
+ {
+ MaskProvider = new MaskedTextProvider(Mask, Culture, true, PromptChar, PasswordChar, AsciiOnly) { ResetOnSpace = ResetOnSpace, ResetOnPrompt = ResetOnPrompt };
+ if (Text != null)
+ {
+ MaskProvider.Set(Text);
+ }
+ RefreshText(MaskProvider, 0);
+ }
+ if (change.Property == TextProperty && MaskProvider != null && _ignoreTextChanges == false)
+ {
+ if (string.IsNullOrEmpty(Text))
+ {
+ MaskProvider.Clear();
+ RefreshText(MaskProvider, CaretIndex);
+ base.OnPropertyChanged(change);
+ return;
+ }
+
+ MaskProvider.Set(Text);
+ RefreshText(MaskProvider, CaretIndex);
+ }
+ else if (change.Property == MaskProperty)
+ {
+ UpdateMaskProvider();
+
+ if (!string.IsNullOrEmpty(Mask))
+ {
+ foreach (var c in Mask!)
+ {
+ if (!MaskedTextProvider.IsValidMaskChar(c))
+ {
+ throw new ArgumentException("Specified mask contains characters that are not valid.");
+ }
+ }
+ }
+ }
+ else if (change.Property == PasswordCharProperty)
+ {
+ if (!MaskedTextProvider.IsValidPasswordChar(PasswordChar))
+ {
+ throw new ArgumentException("Specified character value is not allowed for this property.", nameof(PasswordChar));
+ }
+ if (MaskProvider != null && PasswordChar == MaskProvider.PromptChar)
+ {
+ // Prompt and password chars must be different.
+ throw new InvalidOperationException("PasswordChar and PromptChar values cannot be the same.");
+ }
+ if (MaskProvider != null && MaskProvider.PasswordChar != PasswordChar)
+ {
+ UpdateMaskProvider();
+ }
+ }
+ else if (change.Property == PromptCharProperty)
+ {
+ if (!MaskedTextProvider.IsValidInputChar(PromptChar))
+ {
+ throw new ArgumentException("Specified character value is not allowed for this property.");
+ }
+ if (PromptChar == PasswordChar)
+ {
+ throw new InvalidOperationException("PasswordChar and PromptChar values cannot be the same.");
+ }
+ if (MaskProvider != null && MaskProvider.PromptChar != PromptChar)
+ {
+ UpdateMaskProvider();
+ }
+ }
+ else if (change.Property == AsciiOnlyProperty && MaskProvider != null && MaskProvider.AsciiOnly != AsciiOnly
+ || change.Property == CultureProperty && MaskProvider != null && !MaskProvider.Culture.Equals(Culture))
+ {
+ UpdateMaskProvider();
+ }
+ base.OnPropertyChanged(change);
+ }
+ protected override void OnTextInput(TextInputEventArgs e)
+ {
+ _ignoreTextChanges = true;
+ try
+ {
+ if (IsReadOnly)
+ {
+ e.Handled = true;
+ base.OnTextInput(e);
+ return;
+ }
+ if (MaskProvider == null)
+ {
+ base.OnTextInput(e);
+ return;
+ }
+ if ((MaskProvider.ResetOnSpace && e.Text == " " || MaskProvider.ResetOnPrompt && e.Text == MaskProvider.PromptChar.ToString()) && !string.IsNullOrEmpty(SelectedText))
+ {
+ if (SelectionStart > SelectionEnd ? MaskProvider.RemoveAt(SelectionEnd, SelectionStart - 1) : MaskProvider.RemoveAt(SelectionStart, SelectionEnd - 1))
+ {
+ SelectedText = string.Empty;
+ }
+ }
+
+ if (CaretIndex < Text.Length)
+ {
+ CaretIndex = GetNextCharacterPosition(CaretIndex);
+
+ if (MaskProvider.InsertAt(e.Text, CaretIndex))
+ {
+ CaretIndex++;
+ }
+ var nextPos = GetNextCharacterPosition(CaretIndex);
+ if (nextPos != 0 && CaretIndex != Text.Length)
+ {
+ CaretIndex = nextPos;
+ }
+ }
+
+ RefreshText(MaskProvider, CaretIndex);
+
+
+ e.Handled = true;
+
+ base.OnTextInput(e);
+ }
+ finally
+ {
+ _ignoreTextChanges = false;
+ }
+
+ }
+
+ private int GetNextCharacterPosition(int startPosition)
+ {
+ if (MaskProvider != null)
+ {
+ var position = MaskProvider.FindEditPositionFrom(startPosition, true);
+ if (CaretIndex != -1)
+ {
+ return position;
+ }
+ }
+ return startPosition;
+ }
+
+ private void RefreshText(MaskedTextProvider provider, int position)
+ {
+ if (provider != null)
+ {
+ Text = provider.ToDisplayString();
+ CaretIndex = position;
+ }
+ }
+
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs
new file mode 100644
index 00000000000..1a251a5cef4
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs
@@ -0,0 +1,990 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Reactive.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+ public class MaskedTextBoxTests
+ {
+ [Fact]
+ public void Opening_Context_Menu_Does_not_Lose_Selection()
+ {
+ using (Start(FocusServices))
+ {
+ var target1 = new MaskedTextBox
+ {
+ Template = CreateTemplate(),
+ Text = "1234",
+ ContextMenu = new TestContextMenu()
+ };
+
+ var target2 = new MaskedTextBox
+ {
+ Template = CreateTemplate(),
+ Text = "5678"
+ };
+
+ var sp = new StackPanel();
+ sp.Children.Add(target1);
+ sp.Children.Add(target2);
+
+ target1.ApplyTemplate();
+ target2.ApplyTemplate();
+
+ var root = new TestRoot() { Child = sp };
+
+ target1.SelectionStart = 0;
+ target1.SelectionEnd = 3;
+
+ target1.Focus();
+ Assert.False(target2.IsFocused);
+ Assert.True(target1.IsFocused);
+
+ target2.Focus();
+
+ Assert.Equal("123", target1.SelectedText);
+ }
+ }
+
+ [Fact]
+ public void Opening_Context_Flyout_Does_not_Lose_Selection()
+ {
+ using (Start(FocusServices))
+ {
+ var target1 = new MaskedTextBox
+ {
+ Template = CreateTemplate(),
+ Text = "1234",
+ ContextFlyout = new MenuFlyout
+ {
+ Items = new List