From a04a0f07e207eeeefbe67b4580b389c7f048090e Mon Sep 17 00:00:00 2001 From: stm Date: Fri, 13 Jan 2023 12:19:02 +0100 Subject: [PATCH] Progress --- .../Components/Form/FormComponent.cs | 12 +- .../Components/Input/DebouncedInput.cs | 1 - .../Components/Input/Input.razor | 263 ++++++++------- .../Components/Input/Input.razor.cs | 311 ++++++++++++++++-- .../Components/Input/InputBase.cs | 9 +- .../Components/Picker/Picker.razor.cs | 2 +- .../Components/TextField/TextField.razor | 12 +- .../Components/TextField/TextField.razor.cs | 27 +- 8 files changed, 477 insertions(+), 160 deletions(-) diff --git a/src/Connected.Components/Components/Form/FormComponent.cs b/src/Connected.Components/Components/Form/FormComponent.cs index bdecafb..a6e4fd3 100644 --- a/src/Connected.Components/Components/Form/FormComponent.cs +++ b/src/Connected.Components/Components/Form/FormComponent.cs @@ -139,11 +139,11 @@ public abstract class FormComponent : UIComponent, IFormComponent, IDispos /// public bool HasErrors => HasError || ConversionError || ValidationErrors.Count > 0; - /// - /// Return the validation error text or the conversion error message. - /// - /// Error text/message - public string? GetErrorText() + /// + /// Return the validation error text or the conversion error message. + /// + /// Error text/message + public string? GetErrorText(bool test = false) { // ErrorText is either set from outside or the first validation error if (!IsNullOrWhiteSpace(ErrorText)) @@ -152,6 +152,8 @@ public abstract class FormComponent : UIComponent, IFormComponent, IDispos if (!IsNullOrWhiteSpace(ConversionErrorMessage)) return ConversionErrorMessage; + if (test) return "Error: test"; + return null; } diff --git a/src/Connected.Components/Components/Input/DebouncedInput.cs b/src/Connected.Components/Components/Input/DebouncedInput.cs index b899cfd..10babcf 100644 --- a/src/Connected.Components/Components/Input/DebouncedInput.cs +++ b/src/Connected.Components/Components/Input/DebouncedInput.cs @@ -1,6 +1,5 @@ using System.Timers; using Connected.Annotations; -using Connected.Utilities.BindingConverters; using Microsoft.AspNetCore.Components; namespace Connected.Components; diff --git a/src/Connected.Components/Components/Input/Input.razor b/src/Connected.Components/Components/Input/Input.razor index 8a2751a..6f1fcf4 100644 --- a/src/Connected.Components/Components/Input/Input.razor +++ b/src/Connected.Components/Components/Input/Input.razor @@ -3,6 +3,23 @@ @inherits InputBase
+ + + + @if (Adornment == Adornment.Start) { } - - @if (Lines > 1) - { - - @*Note: double mouse wheel handlers needed for Firefox because it doesn't know onmousewheel*@ - @*note: the value="@_internalText" is absolutely essential here. the inner html @Text is needed by tests checking it*@ - } - else - { - - @*Note: double mouse wheel handlers needed for Firefox because it doesn't know onmousewheel*@ + + @if (Lines > 1) + { + + + @*Note: double mouse wheel handlers needed for Firefox because it doesn't know onmousewheel*@ + @*note: the value="@_internalText" is absolutely essential here. the inner html @Text is needed by tests checking it*@ + } + else + { + + @*Note: double mouse wheel handlers needed for Firefox because it doesn't know onmousewheel*@ - @if (Disabled) { - @*Note: this div must always be there to avoid crashes in WASM, but it is hidden most of the time except if ChildContent should be shown. - In Disabled state the tabindex attribute must NOT be set at all or else it will get focus on click - *@ -
- @ChildContent -
- } - else + @if (Disabled) { + @*Note: this div must always be there to avoid crashes in WASM, but it is hidden most of the time except if ChildContent should be shown. + In Disabled state the tabindex attribute must NOT be set at all or else it will get focus on click + *@ +
+ @ChildContent +
+ } + else + { + @*Note: this div must always be there to avoid crashes in WASM, but it is hidden most of the time except if ChildContent should be shown.*@ +
+ @ChildContent +
+ } + } + + @if (_showClearable && !Disabled) { - @*Note: this div must always be there to avoid crashes in WASM, but it is hidden most of the time except if ChildContent should be shown.*@ -
- @ChildContent -
+ } - } + + + @if (Adornment == Adornment.End) + { + + } - @if (_showClearable && !Disabled) - { - - } + @if (Variant == Variant.Outlined) + { +
+ } - @if (Adornment == Adornment.End) - { - - } + @if (!HideSpinButtons) + { +
+ + +
+ } + +
+ +
+ +
+
- @if (Variant == Variant.Outlined) - { -
- } - @if (!HideSpinButtons) - { -
- - -
- } - \ No newline at end of file diff --git a/src/Connected.Components/Components/Input/Input.razor.cs b/src/Connected.Components/Components/Input/Input.razor.cs index 8c7962b..7a70bf4 100644 --- a/src/Connected.Components/Components/Input/Input.razor.cs +++ b/src/Connected.Components/Components/Input/Input.razor.cs @@ -2,12 +2,192 @@ using Connected.Utilities; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using System.Numerics; +using System.Text.RegularExpressions; +using System.Timers; namespace Connected.Components; public partial class Input : InputBase { - protected string Classname => InputCssHelper.GetClassname(this, + + /* + * Debounce + * + */ + + + /// + /// The current character counter, displayed below the text field. + /// + [Parameter] public string CounterText { get; set; } + + private System.Timers.Timer _timer; + private double _debounceInterval; + + /// + /// Interval to be awaited in MILLISECONDS before changing the Text value + /// + [Parameter] + public double TextChangeDelay + { + get => _debounceInterval; + set + { + if (NumericConverter.AreEqual(_debounceInterval, value)) + return; + _debounceInterval = value; + if (_debounceInterval == 0) + { + // not debounced, dispose timer if any + ClearTimer(suppressTick: false); + return; + } + SetTimer(); + } + } + + /// + /// callback to be called when the debounce interval has elapsed + /// receives the Text as a parameter + /// + [Parameter] public EventCallback OnDebounceIntervalElapsed { get; set; } + + protected Task OnDebounceChange() + { + + if (TextChangeDelay > 0 && _timer != null) + { + _timer.Stop(); + return base.UpdateValuePropertyAsync(false); + } + return Task.CompletedTask; + } + + protected override Task UpdateValuePropertyAsync(bool updateText) + { + // This method is called when Value property needs to be refreshed from the current Text property, so typically because Text property has changed. + // We want to debounce only text-input, not a value being set, so the debouncing is only done when updateText==false (because that indicates the + // change came from a Text setter) + if (updateText) + { + // we have a change coming not from the Text setter, no debouncing is needed + return base.UpdateValuePropertyAsync(updateText); + } + // if debounce interval is 0 we update immediately + if (TextChangeDelay <= 0 || _timer == null) + return base.UpdateValuePropertyAsync(updateText); + // If a debounce interval is defined, we want to delay the update of Value property. + _timer.Stop(); + // restart the timer while user is typing + _timer.Start(); + return Task.CompletedTask; + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + // if input is to be debounced, makes sense to bind the change of the text to oninput + // so we set Immediate to true + if (TextChangeDelay > 0) + Immediate = true; + } + + private void SetTimer() + { + if (_timer == null) + { + _timer = new System.Timers.Timer(); + _timer.Elapsed += OnTimerTick; + _timer.AutoReset = false; + } + _timer.Interval = TextChangeDelay; + } + + private void OnTimerTick(object sender, ElapsedEventArgs e) + { + InvokeAsync(OnTimerTickGuiThread).AndForget(); + } + + private async Task OnTimerTickGuiThread() + { + await base.UpdateValuePropertyAsync(false); + await OnDebounceIntervalElapsed.InvokeAsync(Text); + } + + private void ClearTimer(bool suppressTick = false) + { + if (_timer == null) + return; + var wasEnabled = _timer.Enabled; + _timer.Stop(); + _timer.Elapsed -= OnTimerTick; + _timer.Dispose(); + _timer = null; + if (wasEnabled && !suppressTick) + OnTimerTickGuiThread().AndForget(); + } + + /* + * Debounce end + */ + + protected CssBuilder CompiledHelperContainerClassList + { + get + { + return new CssBuilder("input-control-helper-container") + .AddClass($"px-1", Variant == Variant.Filled) + .AddClass($"px-2", Variant == Variant.Outlined) + .AddClass($"px-1", Variant == Variant.Text) + .AddClass(HelperContainerClassList); + } + } + + /// + /// A space separated list of class names, added on top of the default helper container class list. + /// + [Parameter] + public string? HelperContainerClassList { get; set; } + + protected CssBuilder CompiledHelperClassList + { + get + { + return new CssBuilder("input-helper-text") + .AddClass("input-helper-onfocus", HelperTextOnFocus) + .AddClass(HelperClassList); + } + } + + /// + /// A space separated list of class names, added on top of the default helper class list. + /// + [Parameter] + public string? HelperClassList { get; set; } + + + + /*protected string HelperContainer => + new CssBuilder("input-control-helper-container") + .AddClass($"px-1", Variant == Variant.Filled) + .AddClass($"px-2", Variant == Variant.Outlined) + .Build(); + + + protected string HelperClass => + new CssBuilder("input-helper-text") + .AddClass("input-helper-onfocus", HelperTextOnFocus) + .Build();*/ + + private string GetCounterText() + { + string result = Text.Length.ToString(); + if (string.IsNullOrEmpty(Text)) result = "0"; + return result; + } + + protected string Classname => InputCssHelper.GetClassname(this, () => HasNativeHtmlPlaceholder() || !string.IsNullOrEmpty(Text) || Adornment == Adornment.Start || !string.IsNullOrWhiteSpace(Placeholder)); protected string InputClassname => InputCssHelper.GetInputClassname(this); @@ -31,23 +211,89 @@ public partial class Input : InputBase protected string InputTypeString => InputType.ToDescription(); - protected Task OnInput(ChangeEventArgs args) + /* + + private bool IsDecimalNumber(string type) + { + switch (type.ToLower()) + { + case "double": + case "float": + return true; + default: + return false; + } + } + + private bool IsNumber(string s) + { + bool result = false; + try + { + double d; + result= double.TryParse(s, out d); + } catch + { + result = false; + } + return result; + } + + private string ValidateInput(string value) + { + string result = value; + if (value is not null) + { + var expectedType = typeof(T).Name; + if (IsNumericType(expectedType)) + { + if (IsNumber(value.ToString())) + { + result = value.ToString(); + } + else + { + if (IsDecimalNumber(value.ToString())) + result = Regex.Replace(value.ToString(), "[^0-9.]", ""); + else result = Regex.Replace(value.ToString(), "[^0-9]", ""); + } + } + else + { + result = value; + } + } + return result; + }*/ + + + protected Task OnInput(ChangeEventArgs args) { if (!Immediate) return Task.CompletedTask; _isFocused = true; - return SetTextAsync(args?.Value as string); - } + return SetTextAsync(args?.Value as string); + + } protected async Task OnChange(ChangeEventArgs args) { - _internalText = args?.Value as string; - await OnInternalInputChanged.InvokeAsync(args); - if (!Immediate) - { - await SetTextAsync(args?.Value as string); - } - } + if (TextChangeDelay > 0 && _timer != null) + { + _timer.Stop(); + await UpdateValuePropertyAsync(false); + } + else + { + _internalText = args?.Value as string; + await OnInternalInputChanged.InvokeAsync(args); + if (!Immediate) + { + await SetTextAsync(args?.Value as string); + } + } + + } /// /// Paste hook for descendants. @@ -164,12 +410,12 @@ public partial class Input : InputBase UpdateClearable(Text); } - protected override async Task UpdateValuePropertyAsync(bool updateText) + /*protected override async Task UpdateValuePropertyAsync(bool updateText) { await base.UpdateValuePropertyAsync(updateText); if (Clearable) UpdateClearable(Value); - } + }*/ protected virtual async Task ClearButtonClickHandlerAsync(MouseEventArgs e) { @@ -197,14 +443,41 @@ public partial class Input : InputBase // in WASM (or in BSS with TextUpdateSuppression==false) we always update _internalText = Text; } + + string baseTypeName = typeof(T).Name; + if (IsNumericType(baseTypeName)) + { + InputType = InputType.Number; + } + } - /// - /// Sets the input text from outside programmatically - /// - /// - /// - public Task SetText(string text) + private bool IsNumericType(string type) + { + switch (type.ToLower()) + { + case "uint16": + case "uint32": + case "uint64": + case "int16": + case "int32": + case "int64": + case "int": + case "double": + case "decimal": + case "float": + return true; + default: + return false; + } + } + + /// + /// Sets the input text from outside programmatically + /// + /// + /// + public Task SetText(string text) { _internalText = text; return SetTextAsync(text); diff --git a/src/Connected.Components/Components/Input/InputBase.cs b/src/Connected.Components/Components/Input/InputBase.cs index 2861883..e95d4f9 100644 --- a/src/Connected.Components/Components/Input/InputBase.cs +++ b/src/Connected.Components/Components/Input/InputBase.cs @@ -211,7 +211,7 @@ public abstract class InputBase : FormComponent protected virtual async Task SetTextAsync(string text, bool updateValue = true) { - if (Text != text) + if (Text != text) { Text = text; if (!string.IsNullOrWhiteSpace(Text)) @@ -220,6 +220,7 @@ public abstract class InputBase : FormComponent await UpdateValuePropertyAsync(false); await TextChanged.InvokeAsync(Text); } + } /// @@ -329,7 +330,11 @@ public abstract class InputBase : FormComponent /// Fired when the Value property changes. /// [Parameter] - public EventCallback ValueChanged { get; set; } + public EventCallback ValueChanged + { + get; + set; + } /// /// The value of this input element. diff --git a/src/Connected.Components/Components/Picker/Picker.razor.cs b/src/Connected.Components/Components/Picker/Picker.razor.cs index 1365d38..6a4ce07 100644 --- a/src/Connected.Components/Components/Picker/Picker.razor.cs +++ b/src/Connected.Components/Components/Picker/Picker.razor.cs @@ -387,7 +387,7 @@ public partial class Picker : FormComponent protected override void ResetValue() { - _inputReference?.Reset(); + _inputReference?.InputReference.Reset(); base.ResetValue(); } diff --git a/src/Connected.Components/Components/TextField/TextField.razor b/src/Connected.Components/Components/TextField/TextField.razor index 9b11446..3479358 100644 --- a/src/Connected.Components/Components/TextField/TextField.razor +++ b/src/Connected.Components/Components/TextField/TextField.razor @@ -1,15 +1,15 @@ @namespace Connected.Components @typeparam T -@inherits DebouncedInput +@inherits InputBase - + } else @@ -83,8 +83,10 @@ OnAdornmentClick="@OnAdornmentClick" Error="@HasError" Immediate="@Immediate" - Margin="@Margin" OnBlur="@OnBlurred" + Margin="@Margin" + OnBlur="@OnBlurred" Clearable="@Clearable" + Class="@CompiledClassList.Build()" OnClearButtonClick="@OnClearButtonClick"/> } diff --git a/src/Connected.Components/Components/TextField/TextField.razor.cs b/src/Connected.Components/Components/TextField/TextField.razor.cs index 97a3be9..636cda7 100644 --- a/src/Connected.Components/Components/TextField/TextField.razor.cs +++ b/src/Connected.Components/Components/TextField/TextField.razor.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components.Web; namespace Connected.Components; -public partial class TextField : DebouncedInput +public partial class TextField : InputBase { private Mask? _maskReference; @@ -19,7 +19,7 @@ public partial class TextField : DebouncedInput internal override InputType GetInputType() => InputType; - private string GetCounterText() => Counter == null ? string.Empty : (Counter == 0 ? (string.IsNullOrEmpty(Text) ? "0" : $"{Text.Length}") : ((string.IsNullOrEmpty(Text) ? "0" : $"{Text.Length}") + $" / {Counter}")); + private string GetCounterText() => base.Counter == null ? string.Empty : (base.Counter == 0 ? (string.IsNullOrEmpty(base.Text) ? "0" : $"{base.Text.Length}") : ((string.IsNullOrEmpty(base.Text) ? "0" : $"{base.Text.Length}") + $" / {base.Counter}")); /// /// Show clear button. @@ -32,10 +32,25 @@ public partial class TextField : DebouncedInput /// [Parameter] public EventCallback OnClearButtonClick { get; set; } - protected string ClassList => + /*protected string ClassList => new CssBuilder("input-input-control") .AddClass(base.AdditionalClassList) - .Build(); + .Build();*/ + + protected virtual CssBuilder CompiledClassList + { + get + { + return new CssBuilder("input-input-control") + .AddClass(ClassList); + } + } + + /// + /// A space separated list of class names, added on top of the default class list. + /// + [Parameter] + public string? ClassList { get; set; } public override ValueTask FocusAsync() { @@ -128,10 +143,10 @@ public partial class TextField : DebouncedInput { if (_mask != null) { - var textValue = Converter.Convert(value); + var textValue = base.Converter.Convert(value); _mask.SetText(textValue); textValue = Mask.GetCleanText(); - value = Converter.ConvertBack(textValue); + value = base.Converter.ConvertBack(textValue); } return base.SetValueAsync(value, updateText); }