diff --git a/Radzen.Blazor.Tests/TimeSpanPickerTests.cs b/Radzen.Blazor.Tests/TimeSpanPickerTests.cs new file mode 100644 index 00000000000..3a81efd8814 --- /dev/null +++ b/Radzen.Blazor.Tests/TimeSpanPickerTests.cs @@ -0,0 +1,1187 @@ +using AngleSharp.Css.Dom; +using Bunit; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using Xunit; + +namespace Radzen.Blazor.Tests +{ + public class TimeSpanPickerTests + { + const string _pickerSelector = ".rz-timespanpicker"; + const string _popupButtonSelector = $".rz-timespanpicker-trigger"; + const string _clearButtonSelector = $"{_pickerSelector} > .rz-dropdown-clear-icon"; + const string _inputFieldSelector = $"{_pickerSelector} > .rz-inputtext"; + const string _panelPopupContainerSelector = ".rz-timespanpicker-popup-container"; + const string _panelSelector = ".rz-timespanpicker-panel"; + const string _confirmationButtonSelector = ".rz-timespanpicker-confirmationbutton"; + static readonly Dictionary _unitElementSelectors = new() + { + { TimeSpanUnit.Day, ".rz-timespanpicker-days" }, + { TimeSpanUnit.Hour, ".rz-timespanpicker-hours" }, + { TimeSpanUnit.Minute, ".rz-timespanpicker-minutes" }, + { TimeSpanUnit.Second, ".rz-timespanpicker-seconds" }, + { TimeSpanUnit.Millisecond, ".rz-timespanpicker-milliseconds" }, + { TimeSpanUnit.Microsecond, ".rz-timespanpicker-microseconds" } + }; + const string _signPickerSelector = ".rz-timespanpicker-signpicker"; + const string _positiveSignPickerSelector = $"{_signPickerSelector} > .rz-button:first-child"; + const string _negativeSignPickerSelector = $"{_signPickerSelector} > .rz-button:last-child"; + + static readonly Dictionary _unitSpans = new() + { + { TimeSpanUnit.Day, TimeSpan.FromDays(1) }, + { TimeSpanUnit.Hour, TimeSpan.FromHours(1) }, + { TimeSpanUnit.Minute, TimeSpan.FromMinutes(1) }, + { TimeSpanUnit.Second, TimeSpan.FromSeconds(1) }, + { TimeSpanUnit.Millisecond, TimeSpan.FromMilliseconds(1) }, + { TimeSpanUnit.Microsecond, TimeSpan.FromMicroseconds(1) } + }; + public static IEnumerable TimeSpanUnitsForTheory + => Enum.GetValues().Select(x => new object[] { x }); + + static readonly CultureInfo _cultureInfo = CultureInfo.InvariantCulture; + + #region Component general look + [Fact] + public void TimeSpanPicker_Renders_StyleParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var style = "width: 200px"; + component.SetParametersAndRender(parameters => { + parameters.Add(p => p.Style, style); + }); + + var picker = component.Find(_pickerSelector); + Assert.Contains(style, picker.GetStyle().CssText); + } + + [Fact] + public void TimeSpanPicker_DoesNotRender_Component_WhenVisibleParameterIsFalse() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => { + parameters.Add(p => p.Visible, false); + }); + + var pickerElements = component.FindAll(_pickerSelector); + Assert.Equal(0, pickerElements.Count); + } + + [Fact] + public void TimeSpanPicker_Renders_UnmatchedParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var parameterName = "autofocus"; + var parameterValue = "true"; + component.SetParametersAndRender(parameters => + { + parameters.AddUnmatched(parameterName, parameterValue); + }); + + var picker = component.Find(_pickerSelector); + Assert.Equal(parameterValue, picker.GetAttribute(parameterName)); + } + + [Fact] + public void TimeSpanPicker_Renders_EmptyCssClass_WhenValueIsEmpty() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, null); + }); + + var picker = component.Find(_pickerSelector); + Assert.Contains("rz-state-empty", picker.ClassList); + } + + [Fact] + public void TimeSpanPicker_DoesNotRender_EmptyCssClass_WhenValueIsNotEmpty() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var value = TimeSpan.FromHours(1); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, value); + }); + + var picker = component.Find(_pickerSelector); + Assert.DoesNotContain("rz-state-empty", picker.ClassList); + } + #endregion + + #region Component general behavior //TO DO + + #endregion + + #region Input field look + [Fact] + public void TimeSpanPicker_Renders_InputAttributesParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var parameterName = "autofocus"; + var parameterValue = "true"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.InputAttributes, new Dictionary() { { parameterName, parameterValue } }); + }); + + var inputField = component.Find(_inputFieldSelector); + Assert.Equal(parameterValue, inputField.GetAttribute(parameterName)); + } + + [Fact] + public void TimeSpanPicker_Renders_InputClassParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var inputClass = "test-class"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.InputClass, inputClass); + }); + + var inputField = component.Find(_inputFieldSelector); + Assert.Contains(inputClass, inputField.ClassList); + } + + [Fact] + public void TimeSpanPicker_Renders_NameParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var name = "timespanpicker-test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Name, name); + }); + + var inputField = component.Find(_inputFieldSelector); + Assert.Equal(name, inputField.GetAttribute("name")); + } + + [Fact] + public void TimeSpanPicker_Renders_AllowClearParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.AllowClear, true); + parameters.Add(p => p.Value, TimeSpan.FromDays(1)); + }); + + var clearButtons = component.FindAll(_clearButtonSelector); + Assert.Equal(1, clearButtons.Count); + } + + [Fact] + public void TimeSpanPicker_Renders_AllowInputParameter_WhenFalse() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.AllowInput, false); + }); + + var inputField = component.Find(_inputFieldSelector); + + Assert.True(inputField.HasAttribute("readonly")); + } + + [Fact] + public void TimeSpanPicker_Renders_DisabledParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Disabled, true); + }); + + var picker = component.Find(_pickerSelector); + var inputField = component.Find(_inputFieldSelector); + + Assert.Contains("rz-state-disabled", picker.ClassList); + Assert.True(inputField.HasAttribute("disabled")); + } + + [Fact] + public void TimeSpanPicker_Renders_ReadOnlyParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ReadOnly, true); + }); + + var inputField = component.Find(_inputFieldSelector); + + Assert.Contains("rz-readonly", inputField.ClassList); + Assert.True(inputField.HasAttribute("readonly")); + } + + [Fact] + public void TimeSpanPicker_Renders_ShowPopupButtonParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ShowPopupButton, true); + }); + + var triggerButtons = component.FindAll(_popupButtonSelector); + Assert.Equal(1, triggerButtons.Count); + } + + [Fact] + public void TimeSpanPicker_Renders_PopupButtonClassParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var inputClass = "test-class"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ShowPopupButton, true); + parameters.Add(p => p.PopupButtonClass, inputClass); + }); + + var popupButton = component.Find(".rz-timespanpicker-trigger"); + Assert.Contains(inputClass, popupButton.ClassList); + } + + [Fact] + public void TimeSpanPicker_Renders_TabIndexParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var tabIndex = 15; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.TabIndex, tabIndex); + }); + + var inputField = component.Find(_inputFieldSelector); + Assert.Equal(tabIndex.ToString(_cultureInfo), inputField.GetAttribute("tabindex")); + } + + [Fact] + public void TimeSpanPicker_Renders_TimeSpanFormatParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var format = "d'd 'h'h 'm'min 's's'"; + var value = new TimeSpan(1, 6, 30, 15); + var formattedValue = value.ToString(format, _cultureInfo); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.TimeSpanFormat, format); + parameters.Add(p => p.Value, value); + parameters.Add(p => p.Culture, _cultureInfo); + }); + + var inputField = component.Find(_inputFieldSelector); + Assert.Equal(formattedValue, inputField.GetAttribute("value")); + } + #endregion + + #region Input field texts + [Fact] + public void TimeSpanPicker_Renders_PlaceholderParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var placeholder = "placeholder test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Placeholder, placeholder); + }); + + var inputField = component.Find(_inputFieldSelector); + Assert.Equal(placeholder, inputField.GetAttribute("placeholder")); + } + + [Fact] + public void TimeSpanPicker_Renders_TogglePopupAriaLabelParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var ariaLabel = "aria label test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ShowPopupButton, true); + parameters.Add(p => p.TogglePopupAriaLabel, ariaLabel); + }); + + var inputField = component.Find(_popupButtonSelector); + Assert.Equal(ariaLabel, inputField.GetAttribute("aria-label")); + } + #endregion + + #region Input field behavior + [Fact] + public void TimeSpanPicker_Respects_MinParameter_OnInput() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var minValue = TimeSpan.FromMinutes(-15); + var initialValue = TimeSpan.Zero; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Min, minValue); + parameters.Add(p => p.Value, initialValue); + }); + + var valueToSet = TimeSpan.FromHours(-1); + + component.Find(_inputFieldSelector).Change(valueToSet); + + Assert.Equal(minValue, component.Instance.Value); + } + + [Fact] + public void TimeSpanPicker_Respects_MaxParameter_OnInput() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + var maxValue = TimeSpan.FromMinutes(15); + var initialValue = TimeSpan.Zero; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Max, maxValue); + parameters.Add(p => p.Value, initialValue); + }); + + var valueToSet = TimeSpan.FromHours(1); + + component.Find(_inputFieldSelector).Change(valueToSet); + + Assert.Equal(maxValue, component.Instance.Value); + } + + [Fact] + public void TimeSpanPicker_Parses_Input_Using_TimeSpanFormat() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var format = "h'-'m'-'s"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.TimeSpanFormat, format); + parameters.Add(p => p.Culture, _cultureInfo); + }); + + var expectedValue = new TimeSpan(15, 5, 30); + var input = expectedValue.ToString(format, _cultureInfo); + + var inputElement = component.Find(_inputFieldSelector); + inputElement.Change(input); + + Assert.Equal(expectedValue, component.Instance.Value); + } + + [Fact] + public void TimeSpanPicker_Parses_Input_Using_ParseInput() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + Func customParseInput = (input) => + { + if (TimeSpan.TryParseExact(input, "'- 'h'h 'm'min 's's'", null, TimeSpanStyles.AssumeNegative, out var result)) + { + return result; + } + return null; + }; + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ParseInput, customParseInput); + }); + + var inputElement = component.Find(_inputFieldSelector); + + string input = "- 15h 5min 30s"; + TimeSpan expectedValue = new TimeSpan(15, 5, 30).Negate(); + + inputElement.Change(input); + + Assert.Equal(expectedValue, component.Instance.Value); + } + + [Fact] + public void TimeSpanPicker_Raises_ValueChanged_OnInputChange() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + bool raised = false; + TimeSpan newValue = TimeSpan.Zero; + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ValueChanged, args => { raised = true; newValue = args; }); + parameters.Add(p => p.Culture, _cultureInfo); + }); + + var inputValue = new TimeSpan(5, 15, 30); + var input = inputValue.ToString(null, _cultureInfo); + + var inputElement = component.Find(_inputFieldSelector); + + inputElement.Change(input); + + Assert.True(raised); + Assert.Equal(inputValue, newValue); + } + + [Fact] + public void TimeSpanPicker_Raises_Change_OnInputChange() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + bool raised = false; + TimeSpan? newValue = null; + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Change, args => { raised = true; newValue = args; }); + parameters.Add(p => p.Culture, _cultureInfo); + }); + + var inputValue = new TimeSpan(5, 15, 30); + var input = inputValue.ToString(null, _cultureInfo); + + var inputElement = component.Find(_inputFieldSelector); + + inputElement.Change(input); + + Assert.True(raised); + Assert.Equal(inputValue, newValue); + } + + // TimeSpanPicker_Opens_Popup_OnPopupButtonClick – I don't know how to test it if I can't check if an element is visible + + [Fact] + public void TimeSpanPicker_Raises_ChangeEvent_OnClearButtonClick() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var raised = false; + TimeSpan? value = new TimeSpan(5, 15, 30); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.AllowClear, true); + parameters.Add(p => p.Value, value); + parameters.Add(p => p.Change, args => { raised = true; value = args; }); + }); + + var clearButton = component.Find(_clearButtonSelector); + clearButton.Click(); + + Assert.True(raised); + Assert.Null(value); + } + #endregion + + #region Panel look + private static void TimeSpanPicker_Renders_CorrectValuesInPanelInputs(TimeSpanUnit unit, bool negative) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + + var number = 5; + var value = _unitSpans[unit] * number * (negative ? -1 : 1); // zeros in all units except for one + var min = TimeSpan.MinValue; + var max = TimeSpan.MaxValue; + var component = ctx.RenderComponent>(); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, value); + parameters.Add(p => p.FieldPrecision, TimeSpanUnit.Microsecond); + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + parameters.Add(p => p.Culture, _cultureInfo); + }); + + var expectedValue = number.ToString(_cultureInfo); + + var field = component.Find($"{_unitElementSelectors[unit]} input"); + var fieldValue = field.GetAttribute("value"); + + Assert.Equal(expectedValue, fieldValue); + } + [Theory] + [MemberData(nameof(TimeSpanUnitsForTheory))] + public void TimeSpanPicker_Renders_CorrectValuesInPanelInputs_IfPositiveValue(TimeSpanUnit unit) + => TimeSpanPicker_Renders_CorrectValuesInPanelInputs(unit, false); + [Theory] + [MemberData(nameof(TimeSpanUnitsForTheory))] + public void TimeSpanPicker_Renders_CorrectValuesInPanelInputs_IfNegativeValue(TimeSpanUnit unit) + => TimeSpanPicker_Renders_CorrectValuesInPanelInputs(unit, true); + + [Fact] + public void TimeSpanPicker_Renders_SignButtons_WhenMinNegative_WhenMaxPositive() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var min = TimeSpan.FromDays(-1); + var max = TimeSpan.FromDays(1); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + }); + + var signPickers = component.FindAll(_signPickerSelector); + + Assert.Equal(1, signPickers.Count); + } + + public static TheoryData TimeSpanPicker_DoesNotRender_SignButtons_WhenMinAndMaxHaveSameSignOrZero_Data + => new() + { + {-10, -1}, + {-10, 0}, + {1, 10}, + {0, 10} + }; + [Theory] + [MemberData(nameof(TimeSpanPicker_DoesNotRender_SignButtons_WhenMinAndMaxHaveSameSignOrZero_Data))] + public void TimeSpanPicker_DoesNotRender_SignButtons_WhenMinAndMaxHaveSameSignOrZero(int minDays, int maxDays) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var min = TimeSpan.FromDays(minDays); + var max = TimeSpan.FromDays(maxDays); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + }); + + var signPickers = component.FindAll(_signPickerSelector); + + Assert.Equal(0, signPickers.Count); + } + + [Fact] + public void TimeSpanPicker_Renders_PopupRenderModeParameter_WhenInitial() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Inline, false); + parameters.Add(p => p.PopupRenderMode, PopupRenderMode.Initial); + }); + + var popupContainer = component.Find(_panelPopupContainerSelector); + var panels = component.FindAll(_panelSelector); + + Assert.Equal(1, panels.Count); + } + + [Fact] + public void TimeSpanPicker_Renders_PopupRenderModeParameter_WhenOnDemand() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Inline, false); + parameters.Add(p => p.PopupRenderMode, PopupRenderMode.OnDemand); + }); + + var popupContainer = component.Find(_panelPopupContainerSelector); + + Assert.False(popupContainer.HasChildNodes); + } + + [Fact] + public void TimeSpanPicker_Renders_InlineParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Inline, true); + }); + + var inputFields = component.FindAll(_inputFieldSelector); + var popupContainers = component.FindAll(_panelPopupContainerSelector); + var panels = component.FindAll(_panelSelector); + + Assert.Equal(0, inputFields.Count); + Assert.Equal(0, popupContainers.Count); + Assert.Equal(1, panels.Count); + } + #endregion + + #region Panel texts + [Fact] + public void TimeSpanPicker_Renders_ShowConfirmationButtonParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ShowConfirmationButton, true); + }); + + var confirmationButtons = component.FindAll(_confirmationButtonSelector); + + Assert.Equal(1, confirmationButtons.Count); + } + + public static TheoryData TimeSpanPicker_Renders_PadTimeValuesParameter_Data + => new() + { + { TimeSpanUnit.Hour, "00"}, + { TimeSpanUnit.Minute, "00"}, + { TimeSpanUnit.Second, "00"}, + { TimeSpanUnit.Millisecond, "000"}, + { TimeSpanUnit.Microsecond, "000"} + }; + [Theory] + [MemberData(nameof(TimeSpanPicker_Renders_PadTimeValuesParameter_Data))] + public void TimeSpanPicker_Renders_PadTimeValuesParameter(TimeSpanUnit unit, string format) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + var number = 5; + var value = new TimeSpan(number, number, number, number, number, number); + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.PadTimeValues, true); + parameters.Add(p => p.Value, value); + parameters.Add(p => p.FieldPrecision, TimeSpanUnit.Microsecond); + parameters.Add(p => p.Culture, _cultureInfo); + }); + + var formattedNumber = number.ToString(format, _cultureInfo); + + var field = component.Find($"{_unitElementSelectors[unit]} input"); + + Assert.Equal(formattedNumber, field.GetAttribute("value")); + } + + [Theory] + [MemberData(nameof(TimeSpanUnitsForTheory))] + public void TimeSpanPicker_Renders_FieldPrecisionParameter(TimeSpanUnit precision) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.JSInterop.SetupModule("_content/Radzen.Blazor/Radzen.Blazor.js"); + + var component = ctx.RenderComponent>(); + + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.FieldPrecision, precision); + }); + + foreach (var unitSelectorPair in _unitElementSelectors) + { + var foundElements = component.FindAll($"{_panelSelector} {unitSelectorPair.Value}"); + var expectedNumberOfElements = unitSelectorPair.Key <= precision ? 1 : 0; + Assert.Equal(expectedNumberOfElements, foundElements.Count); + } + } + + [Fact] + public void TimeSpanPicker_Renders_ConfirmationButtonTextParameter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var label = "confirmation Test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.ShowConfirmationButton, true); + parameters.Add(p => p.ConfirmationButtonText, label); + }); + + var confirmationButton = component.Find(_confirmationButtonSelector); + + Assert.Contains(label, confirmationButton.ToMarkup()); + } + + [Fact] + public void TimeSpanPicker_Renders_SignButtonTextParemeters() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var min = TimeSpan.FromDays(-1); + var max = TimeSpan.FromDays(1); + var positiveText = "positive + Test"; + var negativeText = "negative - Test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + parameters.Add(p => p.PositiveButtonText, positiveText); + parameters.Add(p => p.NegativeButtonText, negativeText); + }); + + var positiveSignPicker = component.Find(_positiveSignPickerSelector); + var negativeSignPicker = component.Find(_negativeSignPickerSelector); + + Assert.Contains(positiveText, positiveSignPicker.ToMarkup()); + Assert.Contains(negativeText, negativeSignPicker.ToMarkup()); + } + + [Fact] + public void TimeSpanPicker_Renders_PositiveValueTextParemeter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var value = TimeSpan.FromDays(1); + var min = TimeSpan.FromDays(0); + var max = TimeSpan.FromDays(10); + string text = "positive + Test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, value); + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + parameters.Add(p => p.PositiveValueText, text); + }); + + var panel = component.Find(_panelSelector); + + Assert.Contains(text, panel.ToMarkup()); + } + + [Fact] + public void TimeSpanPicker_Renders_NegativeValueTextParemeter() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + var value = TimeSpan.FromDays(-1); + var min = TimeSpan.FromDays(-10); + var max = TimeSpan.FromDays(0); + string text = "negative - Test"; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, value); + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + parameters.Add(p => p.NegativeValueText, text); + }); + + var panel = component.Find(_panelSelector); + + Assert.Contains(text, panel.ToMarkup()); + } + + private static void TimeSpanPicker_Renders_UnitTextParameter( + Expression, string>> unitTextParameterSelector, TimeSpanUnit unit) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var unitText = "unit Test 123"; + var component = ctx.RenderComponent>(); + component.SetParametersAndRender(parameters => + { + parameters.Add(unitTextParameterSelector, unitText); + parameters.Add(p => p.FieldPrecision, TimeSpanUnit.Microsecond); + }); + + var unitValuePicker = component.Find(_unitElementSelectors[unit]); + Assert.Contains(unitText, unitValuePicker.ToMarkup()); + } + [Fact] + public void TimeSpanPicker_Renders_DaysUnitTextParameter() + => TimeSpanPicker_Renders_UnitTextParameter(x => x.DaysUnitText, TimeSpanUnit.Day); + [Fact] + public void TimeSpanPicker_Renders_HoursUnitTextParameter() + => TimeSpanPicker_Renders_UnitTextParameter(x => x.HoursUnitText, TimeSpanUnit.Hour); + [Fact] + public void TimeSpanPicker_Renders_MinutesUnitTextParameter() + => TimeSpanPicker_Renders_UnitTextParameter(x => x.MinutesUnitText, TimeSpanUnit.Minute); + [Fact] + public void TimeSpanPicker_Renders_SecondsUnitTextParameter() + => TimeSpanPicker_Renders_UnitTextParameter(x => x.SecondsUnitText, TimeSpanUnit.Second); + [Fact] + public void TimeSpanPicker_Renders_MillisecondsUnitTextParameter() + => TimeSpanPicker_Renders_UnitTextParameter(x => x.MillisecondsUnitText, TimeSpanUnit.Millisecond); + [Fact] + public void TimeSpanPicker_Renders_MicrosecondsUnitTextParameter() + => TimeSpanPicker_Renders_UnitTextParameter(x => x.MicrosecondsUnitText, TimeSpanUnit.Microsecond); + + #endregion + + #region Panel behavior + [Theory] + [MemberData(nameof(TimeSpanUnitsForTheory))] + public void TimeSpanPicker_Raises_ChangeAndValueChanged_OnPanelInputChange(TimeSpanUnit unit) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + TimeSpan value = TimeSpan.Zero; + TimeSpan? newValue = TimeSpan.Zero; + bool raisedChange = false; + bool raisedValueChanged = false; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, value); + parameters.Add(p => p.Change, args => { raisedChange = true; newValue = args; }); + parameters.Add(p => p.ValueChanged, args => { raisedValueChanged = true; newValue = args; }); + parameters.Add(p => p.Culture, _cultureInfo); + parameters.Add(p => p.FieldPrecision, TimeSpanUnit.Microsecond); + }); + + var inputValue = 15; + var input = inputValue.ToString(null, _cultureInfo); + var expectedValue = value + inputValue * _unitSpans[unit]; + + var unitInputFieldSelector = $"{_unitElementSelectors[unit]} input"; + var inputElement = component.Find(unitInputFieldSelector); + + inputElement.Change(input); + + Assert.True(raisedChange, "Change not raised"); + Assert.True(raisedValueChanged, "ValueChanged not raised"); + Assert.Equal(expectedValue, newValue); + } + + [Theory] + [MemberData(nameof(TimeSpanUnitsForTheory))] + public void TimeSpanPicker_DoesNotRaise_ChangeOrValueChanged_OnPanelInputChange_IfConfirmationButtonShown(TimeSpanUnit unit) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + TimeSpan initialValue = TimeSpan.Zero; + bool raisedChange = false; + bool raisedValueChanged = false; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, initialValue); + parameters.Add(p => p.Change, args => { raisedChange = true; }); + parameters.Add(p => p.ValueChanged, args => { raisedValueChanged = true; }); + parameters.Add(p => p.Culture, _cultureInfo); + parameters.Add(p => p.FieldPrecision, TimeSpanUnit.Microsecond); + parameters.Add(p => p.ShowConfirmationButton, true); + }); + + var inputValue = 15; + var input = inputValue.ToString(null, _cultureInfo); + + var unitInputFieldSelector = $"{_unitElementSelectors[unit]} input"; + var inputElement = component.Find(unitInputFieldSelector); + + inputElement.Change(input); + + Assert.False(raisedChange, "Change raised"); + Assert.False(raisedValueChanged, "ValueChanged raised"); + Assert.Equal(initialValue, component.Instance.Value); + } + + private static void TimeSpanPicker_Raises_ChangeAndValueChanged_OnClickSignButton( + TimeSpan initialValue, TimeSpan expectedValue, bool isPositiveButton) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + TimeSpan min = TimeSpan.MinValue; + TimeSpan max = TimeSpan.MaxValue; + TimeSpan? newValue = TimeSpan.Zero; + bool raisedChange = false; + bool raisedValueChanged = false; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, initialValue); + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + parameters.Add(p => p.Change, args => { raisedChange = true; newValue = args; }); + parameters.Add(p => p.ValueChanged, args => { raisedValueChanged = true; newValue = args; }); + }); + + var signButton = component.Find(isPositiveButton ? _positiveSignPickerSelector : _negativeSignPickerSelector); + signButton.Click(); + + Assert.True(raisedChange, "Change not raised"); + Assert.True(raisedValueChanged, "ValueChanged not raised"); + Assert.Equal(expectedValue, newValue); + } + [Fact] + public void TimeSpanPicker_Raises_ChangeOrValueChanged_OnClickPositiveSignButton() + => TimeSpanPicker_Raises_ChangeAndValueChanged_OnClickSignButton( + + new TimeSpan(5, 15, 30).Negate(), new TimeSpan(5, 15, 30), true); + [Fact] + public void TimeSpanPicker_Raises_ChangeAndValueChanged_OnClickNegativeSignButton() + => TimeSpanPicker_Raises_ChangeAndValueChanged_OnClickSignButton( + new TimeSpan(5, 15, 30), new TimeSpan(5, 15, 30).Negate(), false); + + private static void TimeSpanPicker_DoesNotRaise_ChangeOrValueChanged_OnClickSignButton_IfConfirmationButtonShown( + TimeSpan initialValue, bool isPositiveButton) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + TimeSpan min = TimeSpan.MinValue; + TimeSpan max = TimeSpan.MaxValue; + bool raisedChange = false; + bool raisedValueChanged = false; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, initialValue); + parameters.Add(p => p.Min, min); + parameters.Add(p => p.Max, max); + parameters.Add(p => p.Change, args => { raisedChange = true; }); + parameters.Add(p => p.ValueChanged, args => { raisedValueChanged = true; }); + parameters.Add(p => p.ShowConfirmationButton, true); + }); + + var signButton = component.Find(isPositiveButton ? _positiveSignPickerSelector : _negativeSignPickerSelector); + signButton.Click(); + + Assert.False(raisedChange, "Change raised"); + Assert.False(raisedValueChanged, "ValueChanged raised"); + Assert.Equal(initialValue, component.Instance.Value); + } + [Fact] + public void TimeSpanPicker_DoesNotRaise_ChangeOrValueChanged_OnClickPositiveSignButton_IfConfirmationButtonShown() + => TimeSpanPicker_DoesNotRaise_ChangeOrValueChanged_OnClickSignButton_IfConfirmationButtonShown( + -new TimeSpan(5, 15, 30), true); + [Fact] + public void TimeSpanPicker_DoesNotRaise_ChangeOrValueChanged_OnClickNegativeSignButton_IfConfirmationButtonShown() + => TimeSpanPicker_DoesNotRaise_ChangeOrValueChanged_OnClickSignButton_IfConfirmationButtonShown( + new TimeSpan(5, 15, 30), false); + + [Fact] + public void TimeSpanPicker_Raises_ChangeAndValueChanged_OnClickConfirmationButton() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + + TimeSpan initialValue = new TimeSpan(5, 15, 30); + TimeSpan? newValue = TimeSpan.Zero; + bool raisedChange = false; + bool raisedValueChanged = false; + component.SetParametersAndRender(parameters => + { + parameters.Add(p => p.Value, initialValue); + parameters.Add(p => p.Change, args => { raisedChange = true; newValue = args; }); + parameters.Add(p => p.ValueChanged, args => { raisedValueChanged = true; newValue = args; }); + parameters.Add(p => p.Culture, _cultureInfo); + parameters.Add(p => p.ShowConfirmationButton, true); + }); + + var inputValue = 20; + var input = inputValue.ToString(null, _cultureInfo); + var expectedValue = new TimeSpan(inputValue, 15, 30); + + var unitInputFieldSelector = $"{_unitElementSelectors[TimeSpanUnit.Hour]} input"; + var inputElement = component.Find(unitInputFieldSelector); + inputElement.Change(input); + + var confirmationButton = component.Find(_confirmationButtonSelector); + confirmationButton.Click(); + + Assert.True(raisedChange, "Change not raised"); + Assert.True(raisedValueChanged, "ValueChanged not raised"); + Assert.Equal(expectedValue, newValue); + } + + private static void TimeSpanPicker_Respects_StepParameter( + Expression, string>> stepParameterSelector, TimeSpanUnit unit) + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.RenderComponent>(); + var step = 5; + var stepSpan = step * _unitSpans[unit]; + var initialValue = new TimeSpan(1, 2, 3, 4, 5, 6); + component.SetParametersAndRender(parameters => + { + parameters.Add(stepParameterSelector, step.ToString()); + parameters.Add(p => p.Value, initialValue); + parameters.Add(p => p.FieldPrecision, TimeSpanUnit.Microsecond); + }); + + var unitSelector = _unitElementSelectors[unit]; + var expectedValueUp = initialValue + 2 * stepSpan; + var fieldUpButton = component.Find($"{unitSelector} .rz-numeric-up"); + fieldUpButton.Click(); + fieldUpButton.Click(); + Assert.Equal(expectedValueUp, component.Instance.Value); + + var expectedValueDown = expectedValueUp - stepSpan; + var fieldDownButton = component.Find($"{unitSelector} .rz-numeric-down"); + fieldDownButton.Click(); + Assert.Equal(expectedValueDown, component.Instance.Value); + } + [Fact] + public void TimeSpanPicker_Respects_DaysStepParameter() + => TimeSpanPicker_Respects_StepParameter(x => x.DaysStep, TimeSpanUnit.Day); + [Fact] + public void TimeSpanPicker_Respects_HoursStepParameter() + => TimeSpanPicker_Respects_StepParameter(x => x.HoursStep, TimeSpanUnit.Hour); + [Fact] + public void TimeSpanPicker_Respects_MinutesStepParameter() + => TimeSpanPicker_Respects_StepParameter(x => x.MinutesStep, TimeSpanUnit.Minute); + [Fact] + public void TimeSpanPicker_Respects_SecondsStepParameter() + => TimeSpanPicker_Respects_StepParameter(x => x.SecondsStep, TimeSpanUnit.Second); + [Fact] + public void TimeSpanPicker_Respects_MillisecondsStepParameter() + => TimeSpanPicker_Respects_StepParameter(x => x.MillisecondsStep, TimeSpanUnit.Millisecond); + [Fact] + public void TimeSpanPicker_Respects_MicrosecondsStepParameter() + => TimeSpanPicker_Respects_StepParameter(x => x.MicrosecondsStep, TimeSpanUnit.Microsecond); + #endregion + } +} diff --git a/Radzen.Blazor/Common.cs b/Radzen.Blazor/Common.cs index c854055e971..cd4225229a1 100644 --- a/Radzen.Blazor/Common.cs +++ b/Radzen.Blazor/Common.cs @@ -371,6 +371,40 @@ public enum Month December = 11, } + /// + /// Specifies the time unit of . + /// + public enum TimeSpanUnit + { + /// + /// Day. + /// + Day = 0, + /// + /// Hour. + /// + Hour = 1, + /// + /// Minute. + /// + Minute = 2, + /// + /// Second. + /// + Second = 3, + /// + /// Millisecond. + /// + Millisecond = 4 + #if NET7_0_OR_GREATER + , + /// + /// Microsecond. + /// + Microsecond = 5 + #endif + } + /// /// Html editor mode (Rendered or Raw). Also used for toolbar buttons to enable/disable according to mode. /// diff --git a/Radzen.Blazor/RadzenTimeSpanPicker.razor b/Radzen.Blazor/RadzenTimeSpanPicker.razor new file mode 100644 index 00000000000..c4885f9c07a --- /dev/null +++ b/Radzen.Blazor/RadzenTimeSpanPicker.razor @@ -0,0 +1,130 @@ +@using Radzen +@using Radzen.Blazor.Rendering +@using Microsoft.AspNetCore.Components.Forms +@using System.Linq.Expressions +@using System.Globalization +@using Microsoft.JSInterop + +@typeparam TValue +@inherits RadzenComponent +@implements IRadzenFormComponent + +@if (!Visible) +{ + return; +} + +
+ @if (Inline) + { + @RenderPanel() + } + else + { + + @if (ShowPopupButton) + { + + } + @if (AllowClear && HasValue && (_isNullable || ConfirmedValue != DefaultNonNullValue) && !Disabled && !ReadOnly) + { + + } + + @RenderPanel() + + } +
+ +@code { + private RenderFragment RenderPanel() + { + return __builder => + { +
+
+ @if (_canBeEitherPositiveOrNegative && ReadOnly is false) + { + + + + + + + } + else { + var signText = _isUnconfirmedValueNegative ? NegativeValueText : PositiveValueText; + if (string.IsNullOrWhiteSpace(signText) is false) + { +
@signText
+ } + } + + @RenderUnitField(TimeSpanUnit.Day, DaysUnitText, "d", UnconfirmedValue.Days, DaysStep, null, UpdateDays) + + @RenderUnitField(TimeSpanUnit.Hour, HoursUnitText, "h", UnconfirmedValue.Hours, HoursStep, "00", UpdateHours) + + @RenderUnitField(TimeSpanUnit.Minute, MinutesUnitText, "min", UnconfirmedValue.Minutes, MinutesStep, "00", UpdateMinutes) + + @RenderUnitField(TimeSpanUnit.Second, SecondsUnitText, "s", UnconfirmedValue.Seconds, SecondsStep, "00", UpdateSeconds) + + @RenderUnitField(TimeSpanUnit.Millisecond, MillisecondsUnitText, "ms", UnconfirmedValue.Milliseconds, MillisecondsStep, "000", UpdateMilliseconds) + + @{ + #if NET7_0_OR_GREATER + { + @RenderUnitField(TimeSpanUnit.Microsecond, MicrosecondsUnitText, "us", UnconfirmedValue.Microseconds, MicrosecondsStep, "000", UpdateMicroseconds) + } + #endif + } +
+ + @if (ShowConfirmationButton && ReadOnly is false) + { + + @ConfirmationButtonText + + } +
+ }; + } + + private RenderFragment RenderUnitField(TimeSpanUnit unit, string label, string unitSymbol, int value, string step, string valuePaddingFormat, Func change) + { + return __builder => + { + + @if (FieldPrecision >= unit && TimeFieldsMaxValues[unit] > 0) + { + var inputName = $"{UniqueID}-{unitSymbol}"; +
+ + +
+ } +
+ }; + } +} \ No newline at end of file diff --git a/Radzen.Blazor/RadzenTimeSpanPicker.razor.cs b/Radzen.Blazor/RadzenTimeSpanPicker.razor.cs new file mode 100644 index 00000000000..1c6b232e756 --- /dev/null +++ b/Radzen.Blazor/RadzenTimeSpanPicker.razor.cs @@ -0,0 +1,934 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; +using Radzen.Blazor.Rendering; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Radzen.Blazor +{ + /// + /// RadzenTimeSpanPicker component. + /// + /// and nullable are supported. + /// + /// + /// <RadzenTimeSpanPicker @bind-Value="@someValue" TValue="TimeSpan" Change=@(args => Console.WriteLine($"Selected time span: {args}")) /> + /// + /// + public partial class RadzenTimeSpanPicker : RadzenComponent, IRadzenFormComponent + { + #region Parameters: value + private TValue _value; + /// + /// Specifies the value of the component. + /// + [Parameter] + public TValue Value + { + get => _value; + set + { + if (EqualityComparer.Default.Equals(value, _value)) + { + return; + } + + _value = value; + + if (value is null) + { + ConfirmedValue = null; + return; + } + + ConfirmedValue = (TimeSpan?)(object)value; + } + } + /// + /// Specifies the minimum time stamp allowed. + /// + [Parameter] + public TimeSpan Min { get; set; } = TimeSpan.MinValue; + + /// + /// Specifies the maximum time stamp allowed. + /// + [Parameter] + public TimeSpan Max { get; set; } = TimeSpan.MaxValue; + #endregion + + #region Parameters: input field config + /// + /// Specifies whether the value can be cleared. + /// + /// true if value can be cleared; otherwise, false. + [Parameter] + public bool AllowClear { get; set; } + + /// + /// Specifies whether input in the input field is allowed. + /// Set to true by default. + /// + /// true if input is allowed; otherwise, false. + [Parameter] + public bool AllowInput { get; set; } = true; + + /// + /// Specifies whether the input field is disabled. + /// + /// true if disabled; otherwise, false. + [Parameter] + public bool Disabled { get; set; } + + /// + /// Specifies whether the input field is read only. + /// + /// true if read only; otherwise, false. + [Parameter] + public bool ReadOnly { get; set; } + + /// + /// Specifies whether to display popup icon button in the input field. + /// + /// true to display the button to open the popup; + /// false to hide the button to open the popup, clicking the input field opens the popup instead. + [Parameter] + public bool ShowPopupButton { get; set; } = true; + + /// + /// Specifies the popup toggle button CSS classes, separated with spaces. + /// + [Parameter] + public string PopupButtonClass { get; set; } + + /// + /// Specifies additional custom attributes that will be rendered by the input. + /// + [Parameter] + public IReadOnlyDictionary InputAttributes { get; set; } + + /// + /// Specifies the input CSS classes, separated with spaces. + /// + [Parameter] + public string InputClass { get; set; } + + /// + /// Specifies the name of the input field. + /// + [Parameter] + public string Name { get; set; } + + /// + /// Specifies the tab index. + /// + [Parameter] + public int TabIndex { get; set; } = 0; + + /// + /// Specifies the time span format in the input field. + /// For more details, see the documentation of + /// standard + /// and custom + /// time span format strings. + /// + [Parameter] + public string TimeSpanFormat { get; set; } + + /// + /// Specifies custom function to parse the input. + /// If it's not defined or the function it returns null, a built-in parser us used instead. + /// + [Parameter] + public Func ParseInput { get; set; } + #endregion + + #region Parameters: input field texts + /// + /// Specifies the input placeholder. + /// + [Parameter] + public string Placeholder { get; set; } + + /// + /// Specifies the aria label for the toggle popup button. + /// + [Parameter] + public string TogglePopupAriaLabel { get; set; } = "Toggle popup"; + #endregion + + #region Parameters: panel config + /// + /// Specifies the render mode of the popup. + /// + [Parameter] + public PopupRenderMode PopupRenderMode { get; set; } = PopupRenderMode.Initial; + + /// + /// Specifies whether the component is inline or shows a popup. + /// + /// true if inline; false if shows a popup. + [Parameter] + public bool Inline { get; set; } + + /// + /// Specifies whether to display the confirmation button in the panel to accept changes. + /// + /// true if the confirmation button is shown; otherwise, false. + [Parameter] + public bool ShowConfirmationButton { get; set; } = false; + + /// + /// Specifies whether the time fields in the panel, except for the days field, are padded with leading zeros. + /// + /// true if fields are padded; otherwise, false. + [Parameter] + public bool PadTimeValues { get; set; } + + /// + /// Specifies the most precise time unit field in the picker panel. Set to by default. + /// + [Parameter] + public TimeSpanUnit FieldPrecision { get; set; } = TimeSpanUnit.Second; + + /// + /// Specifies the step of the days field in the picker panel. + /// + [Parameter] + public string DaysStep { get; set; } + + /// + /// Specifies the step of the hours field in the picker panel. + /// + [Parameter] + public string HoursStep { get; set; } + + /// + /// Specifies the step of the minutes field in the picker panel. + /// + [Parameter] + public string MinutesStep { get; set; } + + /// + /// Specifies the step of the seconds field in the picker panel. + /// + [Parameter] + public string SecondsStep { get; set; } + + /// + /// Specifies the step of the milliseconds field in the picker panel. + /// + [Parameter] + public string MillisecondsStep { get; set; } + + #if NET7_0_OR_GREATER + /// + /// Specifies the step of the microseconds field in the picker panel. + /// + [Parameter] + public string MicrosecondsStep { get; set; } +#endif + #endregion + + #region Parameters: panel texts + /// + /// Specifies the text of the confirmation button. Used only if is true. + /// + [Parameter] + public string ConfirmationButtonText { get; set; } = "OK"; + + /// + /// Specifies the text of the positive value button. + /// + [Parameter] + public string PositiveButtonText { get; set; } = "+"; + + /// + /// Specifies the text of the negative value button. + /// + [Parameter] + public string NegativeButtonText { get; set; } = "−"; + + /// + /// Specifies the text displayed next to the fields in the panel when the value is positive and there's no sign picker. + /// + [Parameter] + public string PositiveValueText { get; set; } = string.Empty; + + /// + /// Specifies the text displayed next to the fields in the panel when the value is negative and there's no sign picker. + /// + [Parameter] + public string NegativeValueText { get; set; } = "−"; + + /// + /// Specifies the days label text. + /// + [Parameter] + public string DaysUnitText { get; set; } = "Days"; + + /// + /// Specifies the hours label text. + /// + [Parameter] + public string HoursUnitText { get; set; } = "Hours"; + + /// + /// Specifies the minutes label text. + /// + [Parameter] + public string MinutesUnitText { get; set; } = "Minutes"; + + /// + /// Specifies the seconds label text. + /// + [Parameter] + public string SecondsUnitText { get; set; } = "Seconds"; + + /// + /// Specifies the milliseconds label text. + /// + [Parameter] + public string MillisecondsUnitText { get; set; } = "Milliseconds"; + +#if NET7_0_OR_GREATER + /// + /// Specifies the microseconds label text. + /// + [Parameter] + public string MicrosecondsUnitText { get; set; } = "Microseconds"; + #endif + #endregion + + #region Parameters: other config + /// + /// Specifies the value expression used while creating the . + /// + [Parameter] + public Expression> ValueExpression { get; set; } + #endregion + + #region Parameters: events + /// + /// Specifies the callback of the value change. + /// + [Parameter] + public EventCallback ValueChanged { get; set; } + + /// + /// Specifies the callback of the underlying nullable value. + /// + [Parameter] + public EventCallback Change { get; set; } + #endregion + + + #region Form fields + private IRadzenForm _form; + /// + /// Specifies the form this component belongs to. + /// + [CascadingParameter] + public IRadzenForm Form + { + get => _form; + set + { + if (_form == value || value is null) + return; + + _form = value; + _form.AddComponent(this); + } + } + + /// + /// Specifies the edit context of this component. + /// + [CascadingParameter] + public EditContext EditContext { get; set; } + + /// + /// Specifies the context of this component. + /// + public IFormFieldContext FormFieldContext { get; set; } = null; + #endregion + + + #region Calculated properties and references + private static readonly bool _isNullable = Nullable.GetUnderlyingType(typeof(TValue)) is not null; + + private bool PreventValueChange => Disabled || ReadOnly; + private bool PreventPopupToggle => Disabled || ReadOnly || Inline; + + /// + /// Indicates whether this instance has a confirmed value. + /// + /// true if this instance has value; otherwise, false. + public bool HasValue => ConfirmedValue.HasValue; + + /// + /// Indicates whether this instance is bound callback has delegate). + /// + /// true if this instance is bound; otherwise, false. + public bool IsBound => ValueChanged.HasDelegate; + + /// + /// Gets the formatted value. + /// + public string FormattedValue => HasValue ? string.Format(Culture, "{0:" + TimeSpanFormat + "}", ConfirmedValue) : ""; + + /// + /// Gets the field identifier. + /// + public FieldIdentifier FieldIdentifier { get; private set; } + + /// + /// Gets the input reference. + /// + protected ElementReference input; + #endregion + + + #region Underlying values + private TimeSpan? _confirmedValue; + private TimeSpan? ConfirmedValue + { + get => _confirmedValue; + set + { + if (_confirmedValue == value) + { + return; + } + + TimeSpan? newValue = value.HasValue ? AdjustToBounds(value.Value) : + _isNullable ? null : DefaultNonNullValue; + if (_confirmedValue == newValue) + { + return; + } + _confirmedValue = newValue; + + Value = (TValue) (object)_confirmedValue; + + if (ShowConfirmationButton is false) + { + UnconfirmedValue = ConfirmedValue ?? DefaultNonNullValue; + } + } + } + + private TimeSpan _unconfirmedValue; + private TimeSpan UnconfirmedValue + { + get => _unconfirmedValue; + set + { + if (_unconfirmedValue == value) + { + return; + } + + var newValue = AdjustToBounds(value); + if (_unconfirmedValue == newValue) + { + return; + } + + if (newValue != TimeSpan.Zero || _canBeEitherPositiveOrNegative is false) + { + _isUnconfirmedValueNegative = newValue < TimeSpan.Zero; + } + + _unconfirmedValue = newValue; + } + } + + private bool _isUnconfirmedValueNegative = false; + private int UnconformedValueSign => _isUnconfirmedValueNegative ? -1 : 1; + + private TimeSpan DefaultNonNullValue => AdjustToBounds(TimeSpan.Zero); + + private void ResetUnconfirmedValue() + { + UnconfirmedValue = ConfirmedValue ?? DefaultNonNullValue; + _isUnconfirmedValueNegative = UnconfirmedValue < TimeSpan.Zero; + } + private TimeSpan AdjustToBounds(TimeSpan value) => value < Min ? Min : value > Max ? Max : value; + #endregion + + + #region Methods: component general + /// + protected override void OnInitialized() + { + // initial synchronization: necessary when T is not nullable and Value is default(T) + ConfirmedValue = (TimeSpan?)(object)Value; + base.OnInitialized(); + } + + private bool _firstRender; + /// + protected override Task OnAfterRenderAsync(bool firstRender) + { + _firstRender = firstRender; + return base.OnAfterRenderAsync(firstRender); + } + + /// + public override async Task SetParametersAsync(ParameterView parameters) + { + if (parameters.DidParameterChange(nameof(Min), Min) || parameters.DidParameterChange(nameof(Max), Max)) + { + var min = parameters.GetValueOrDefault(nameof(Min)); + var max = parameters.GetValueOrDefault(nameof(Max)); + + SetPanelFieldsSetup(min, max); + } + + var shouldClose = + parameters.DidParameterChange(nameof(Visible), Visible) && parameters.GetValueOrDefault(nameof(Visible)) is false + || parameters.DidParameterChange(nameof(Inline), Inline) && parameters.GetValueOrDefault(nameof(Inline)) + || parameters.DidParameterChange(nameof(Disabled), Disabled) && parameters.GetValueOrDefault(nameof(Disabled)) + || parameters.DidParameterChange(nameof(ReadOnly), ReadOnly) && parameters.GetValueOrDefault(nameof(ReadOnly)); + + await base.SetParametersAsync(parameters); + + if (shouldClose && !_firstRender && IsJSRuntimeAvailable) + { + await ClosePopup(); + } + + if (EditContext != null && ValueExpression != null && FieldIdentifier.Model != EditContext.Model) + { + FieldIdentifier = FieldIdentifier.Create(ValueExpression); + EditContext.OnValidationStateChanged -= ValidationStateChanged; + EditContext.OnValidationStateChanged += ValidationStateChanged; + } + } + + /// + public override void Dispose() + { + base.Dispose(); + + if (EditContext != null) + { + EditContext.OnValidationStateChanged -= ValidationStateChanged; + } + + Form?.RemoveComponent(this); + } + + private async Task OnChange() + { + await ValueChanged.InvokeAsync(Value); + + if (FieldIdentifier.FieldName != null) + { + EditContext?.NotifyFieldChanged(FieldIdentifier); + } + + await Change.InvokeAsync(ConfirmedValue); + } + + private void ValidationStateChanged(object sender, ValidationStateChangedEventArgs e) + { + StateHasChanged(); + } + #endregion + + #region Methods: frontend interactions used externally + /// + /// Closes this instance popup. + /// + public async Task Close() + { + if (Inline) + { + return; + } + + await ClosePopup(); + } + + /// + public async ValueTask FocusAsync() + { + try + { + await input.FocusAsync(); + } + catch + { } + } + #endregion + + #region Methods: other + /// + /// Gets the value of the component. + /// + /// System.Object. + public object GetValue() => Value; + #endregion + + + #region Internal: input text handling + private bool IsInputAllowed => ReadOnly || !AllowInput; + + private async Task Clear() + { + if (PreventValueChange) + { + return; + } + + ConfirmedValue = null; + await ValueChanged.InvokeAsync(Value); + + if (FieldIdentifier.FieldName != null) + { + EditContext?.NotifyFieldChanged(FieldIdentifier); + } + + await Change.InvokeAsync(ConfirmedValue); + } + + private async Task SetValueFromInput(string inputValue) + { + if (PreventValueChange) + { + return; + } + + bool valid = TryParseInput(inputValue, out TimeSpan value); + + TimeSpan? newValue = valid ? AdjustToBounds(value) : null; + + if (ConfirmedValue != newValue && (newValue is not null || _isNullable)) + { + ConfirmedValue = newValue; + + // sometimes onchange is triggered after the popup opens so it won't synchronize the value while opening + UnconfirmedValue = ConfirmedValue ?? DefaultNonNullValue; + + await OnChange(); + } + else + { + await JSRuntime.InvokeAsync("Radzen.setInputValue", input, FormattedValue); + } + } + + private bool TryParseInput(string inputValue, out TimeSpan value) + { + value = TimeSpan.Zero; + bool valid = false; + + if (ParseInput != null) + { + TimeSpan? custom = ParseInput.Invoke(inputValue); + + if (custom.HasValue) + { + valid = true; + value = custom.Value; + } + } + else + { + valid = TimeSpan.TryParseExact(inputValue, TimeSpanFormat, Culture, TimeSpanStyles.None, out value); + + if (!valid) + { + valid = TimeSpan.TryParse(inputValue, Culture, out value); + } + } + + return valid; + } + #endregion + + #region Internal: input mouse and keyboard events + private async Task ClickPopupButton() + { + if (PreventPopupToggle) + { + return; + } + + await TogglePopup(); + await FocusAsync(); + } + + private Task ClickInputField() + => ShowPopupButton ? Task.CompletedTask : ClickPopupButton(); + + private bool _preventKeyPress = false; + private async Task PressKey(KeyboardEventArgs args) + { + if (PreventPopupToggle) + { + return; + } + + var key = args.Code ?? args.Key; + + if (key == "Enter") + { + await TogglePopup(); + } + else if (key == "Escape") + { + await ClosePopup(); + await FocusAsync(); + } + } + #endregion + + #region Internal: popup general actions + private Popup _popup; + + private Task TogglePopup() + => Inline ? Task.CompletedTask : _popup?.ToggleAsync(Element) ?? Task.CompletedTask; + + private Task ClosePopup() + => Inline ? Task.CompletedTask : _popup?.CloseAsync(Element) ?? Task.CompletedTask; + + private async Task PopupKeyDown(KeyboardEventArgs args) + { + var key = args.Code ?? args.Key; + if (key == "Escape") + { + await ClosePopup(); + await FocusAsync(); + } + } + + private void OnPopupOpen() + { + ResetUnconfirmedValue(); + _preventKeyPress = true; + } + private void OnPopupClose() + { + ResetUnconfirmedValue(); + _preventKeyPress = false; + } + #endregion + + #region Internal: panel fields setup + private static readonly Dictionary _timeUnitMaxAbsoluteValues = new() + { + { TimeSpanUnit.Day, TimeSpan.MaxValue.Days }, + { TimeSpanUnit.Hour, 23 }, + { TimeSpanUnit.Minute, 59 }, + { TimeSpanUnit.Second, 59 }, + { TimeSpanUnit.Millisecond, 999 } + #if NET7_0_OR_GREATER + , { TimeSpanUnit.Microsecond, 999 } + #endif + }; + private static readonly Dictionary _timeUnitZeroValues = Enum + .GetValues() + .ToDictionary(x => x, x => 0); + + private Dictionary _negativeTimeFieldsMaxValues = new(_timeUnitMaxAbsoluteValues); + private Dictionary _positiveTimeFieldsMaxValues = new(_timeUnitMaxAbsoluteValues); + private Dictionary TimeFieldsMaxValues + => _isUnconfirmedValueNegative ? _negativeTimeFieldsMaxValues : _positiveTimeFieldsMaxValues; + + private bool _canBeEitherPositiveOrNegative = true; + + private void SetPanelFieldsSetup(TimeSpan min, TimeSpan max) + { + var canBeNegative = min < TimeSpan.Zero; + var canBePositive = max > TimeSpan.Zero; + _canBeEitherPositiveOrNegative = canBeNegative && canBePositive; + + _negativeTimeFieldsMaxValues = canBeNegative ? GetTimeUnitMaxValues(min) : new (_timeUnitZeroValues); + _positiveTimeFieldsMaxValues = canBePositive ? GetTimeUnitMaxValues(max) : new(_timeUnitZeroValues); + } + + private static Dictionary GetTimeUnitMaxValues(TimeSpan boundary) + { + var timeUnitMaxValues = new Dictionary(_timeUnitMaxAbsoluteValues); + + if (boundary.Days != 0) + { + timeUnitMaxValues[TimeSpanUnit.Day] = Math.Abs(boundary.Days); + return timeUnitMaxValues; + } + timeUnitMaxValues[TimeSpanUnit.Day] = 0; + + if (boundary.Hours != 0) + { + timeUnitMaxValues[TimeSpanUnit.Hour] = Math.Abs(boundary.Hours); + return timeUnitMaxValues; + } + timeUnitMaxValues[TimeSpanUnit.Hour] = 0; + + if (boundary.Minutes != 0) + { + timeUnitMaxValues[TimeSpanUnit.Minute] = Math.Abs(boundary.Minutes); + return timeUnitMaxValues; + } + timeUnitMaxValues[TimeSpanUnit.Minute] = 0; + + if (boundary.Seconds != 0) + { + timeUnitMaxValues[TimeSpanUnit.Second] = Math.Abs(boundary.Seconds); + return timeUnitMaxValues; + } + timeUnitMaxValues[TimeSpanUnit.Second] = 0; + + if (boundary.Milliseconds != 0) + { + timeUnitMaxValues[TimeSpanUnit.Millisecond] = Math.Abs(boundary.Milliseconds); + return timeUnitMaxValues; + } + timeUnitMaxValues[TimeSpanUnit.Millisecond] = 0; + +#if NET7_0_OR_GREATER + if (boundary.Microseconds != 0) + { + timeUnitMaxValues[TimeSpanUnit.Microsecond] = Math.Abs(boundary.Microseconds); + return timeUnitMaxValues; + } + timeUnitMaxValues[TimeSpanUnit.Microsecond] = 0; +#endif + + return timeUnitMaxValues; + } +#endregion + + #region Internal: panel value changes + private Task UpdateSign(bool isNegative) + { + if (isNegative && UnconfirmedValue < TimeSpan.Zero + || !isNegative && UnconfirmedValue > TimeSpan.Zero) + { + return Task.CompletedTask; + } + + if (UnconfirmedValue == TimeSpan.Zero) + { + _isUnconfirmedValueNegative = isNegative; + return Task.CompletedTask; + } + + return UpdateValueFromPanelFields(UnconfirmedValue.Negate()); + } + + private Task UpdateDays(int days) + { + var newValue = UnconfirmedValue + - TimeSpan.FromDays(UnconfirmedValue.Days) + + TimeSpan.FromDays(days * UnconformedValueSign); + + return UpdateValueFromPanelFields(newValue); + } + private Task UpdateHours(int hours) + { + var newValue = UnconfirmedValue + - TimeSpan.FromHours(UnconfirmedValue.Hours) + + TimeSpan.FromHours(hours * UnconformedValueSign); + + return UpdateValueFromPanelFields(newValue); + } + private Task UpdateMinutes(int minutes) + { + var newValue = UnconfirmedValue + - TimeSpan.FromMinutes(UnconfirmedValue.Minutes) + + TimeSpan.FromMinutes(minutes * UnconformedValueSign); + + return UpdateValueFromPanelFields(newValue); + } + private Task UpdateSeconds(int seconds) + { + var newValue = UnconfirmedValue + - TimeSpan.FromSeconds(UnconfirmedValue.Seconds) + + TimeSpan.FromSeconds(seconds * UnconformedValueSign); + + return UpdateValueFromPanelFields(newValue); + } + private Task UpdateMilliseconds(int milliseconds) + { + var newValue = UnconfirmedValue + - TimeSpan.FromMilliseconds(UnconfirmedValue.Milliseconds) + + TimeSpan.FromMilliseconds(milliseconds * UnconformedValueSign); + + return UpdateValueFromPanelFields(newValue); + } +#if NET7_0_OR_GREATER + private Task UpdateMicroseconds(int microseconds) + { + var newValue = UnconfirmedValue + - TimeSpan.FromMicroseconds(UnconfirmedValue.Microseconds) + + TimeSpan.FromMicroseconds(microseconds); + + return UpdateValueFromPanelFields(newValue * UnconformedValueSign); + } +#endif + + private Task UpdateValueFromPanelFields(TimeSpan newValue) + { + if (PreventValueChange) + { + return Task.CompletedTask; + } + + UnconfirmedValue = newValue; + + if (ShowConfirmationButton || UnconfirmedValue == ConfirmedValue) + { + return Task.CompletedTask; + } + + ConfirmedValue = UnconfirmedValue; + return OnChange(); + } + + private async Task ConfirmValue() + { + if (ConfirmedValue != UnconfirmedValue) + { + ConfirmedValue = UnconfirmedValue; + await OnChange(); + } + await ClosePopup(); + await FocusAsync(); + } + #endregion + + #region Internal: styles + /// + protected override string GetComponentCssClass() + => ClassList.Create("rz-timespanpicker") + .Add("rz-timespanpicker-inline", Inline) + .AddDisabled(Disabled) + .Add("rz-state-empty", !HasValue) + .Add(FieldIdentifier, EditContext) + .ToString(); + + private string GetInputClass() + => ClassList.Create("rz-inputtext") + .Add(InputClass) + .Add("rz-readonly", ReadOnly && !Disabled) + .ToString(); + + private string GetTogglePopupButtonClass() + => ClassList.Create("rz-timespanpicker-trigger rz-button rz-button-icon-only") + .Add(PopupButtonClass) + .Add("rz-state-disabled", Disabled) + .ToString(); + #endregion + } +} diff --git a/Radzen.Blazor/themes/_components.scss b/Radzen.Blazor/themes/_components.scss index 49ee151c907..01ba78540d8 100644 --- a/Radzen.Blazor/themes/_components.scss +++ b/Radzen.Blazor/themes/_components.scss @@ -75,4 +75,5 @@ @import 'components/blazor/timeline'; @import 'components/blazor/picklist'; @import 'components/blazor/carousel'; +@import 'components/blazor/timespanpicker'; @import 'css-variables'; \ No newline at end of file diff --git a/Radzen.Blazor/themes/_css-variables.scss b/Radzen.Blazor/themes/_css-variables.scss index ff9b1d467f6..2cbcf160a50 100644 --- a/Radzen.Blazor/themes/_css-variables.scss +++ b/Radzen.Blazor/themes/_css-variables.scss @@ -1141,6 +1141,26 @@ --rz-timeline-line-width: #{$rz-timeline-line-width}; --rz-timeline-line-border-radius: #{$rz-timeline-line-border-radius}; + /* TimestampPicker */ + --rz-timespanpicker-line-height: #{$timespanpicker-line-height}; + --rz-timespanpicker-trigger-icon-width: #{$timespanpicker-trigger-icon-width}; + --rz-timespanpicker-trigger-icon-height: #{$timespanpicker-trigger-icon-height}; + --rz-timespanpicker-trigger-icon-color: #{$timespanpicker-trigger-icon-color}; + --rz-timespanpicker-trigger-icon-hover-color: #{$timespanpicker-trigger-icon-hover-color}; + --rz-timespanpicker-focus-outline: #{$timespanpicker-focus-outline}; + --rz-timespanpicker-focus-outline-offset: #{$timespanpicker-focus-outline-offset}; + --rz-timespanpicker-popup-border: #{$timespanpicker-popup-border}; + --rz-timespanpicker-popup-shadow: #{$timespanpicker-popup-shadow}; + --rz-timespanpicker-popup-margin: #{$timespanpicker-popup-margin}; + --rz-timespanpicker-popup-width: #{$timespanpicker-popup-width}; + --rz-timespanpicker-panel-background-color: #{$timespanpicker-panel-background-color}; + --rz-timespanpicker-panel-text-color: #{$timespanpicker-panel-text-color}; + --rz-timespanpicker-panel-padding-block: #{$timespanpicker-panel-padding-block}; + --rz-timespanpicker-panel-padding-inline: #{$timespanpicker-panel-padding-inline}; + --rz-timespanpicker-panel-gap: #{$timespanpicker-panel-gap}; + --rz-timespanpicker-panel-unit-gap: #{$timespanpicker-panel-unit-gap}; + --rz-timespanpicker-panel-field-min-width: #{$timespanpicker-panel-field-min-width}; + /* Tooltip */ --rz-tooltip-background-color: #{$tooltip-background-color}; --rz-tooltip-color: #{$tooltip-color}; diff --git a/Radzen.Blazor/themes/components/blazor/_form-field.scss b/Radzen.Blazor/themes/components/blazor/_form-field.scss index ffce6e14f89..a9514df340d 100644 --- a/Radzen.Blazor/themes/components/blazor/_form-field.scss +++ b/Radzen.Blazor/themes/components/blazor/_form-field.scss @@ -125,7 +125,7 @@ $form-field-helper-padding: 0 0.5rem !default; .rz-form-field-start { padding-inline-end: 0; } - + .rz-form-field-end { padding-inline-start: 0; } @@ -156,12 +156,17 @@ $form-field-helper-padding: 0 0.5rem !default; .rz-textarea { margin-top: 1rem; - } - .rz-datepicker-trigger { + .rz-datepicker-trigger, + .rz-timespanpicker-trigger { top: calc(50% + 0.4375rem); } + + .rz-datepicker .rz-dropdown-clear-icon, + .rz-timespanpicker .rz-dropdown-clear-icon { + top: 0.4375rem; + } } } @@ -173,7 +178,7 @@ $form-field-helper-padding: 0 0.5rem !default; box-shadow: var(--rz-input-hover-shadow); } } - + &.rz-state-focused { .rz-form-field-content { border: var(--rz-input-focus-border); @@ -203,7 +208,7 @@ $form-field-helper-padding: 0 0.5rem !default; background-color: var(--rz-form-field-filled-hover-background-color); } } - + &.rz-state-focused, &.rz-state-focused:hover { .rz-form-field-content { @@ -238,7 +243,7 @@ $form-field-helper-padding: 0 0.5rem !default; .rz-form-field-start { padding-inline-start: 0; } - + .rz-form-field-end { padding-inline-end: 0; } @@ -262,7 +267,7 @@ $form-field-helper-padding: 0 0.5rem !default; inset-inline-end var(--rz-transition), border-width var(--rz-transition); } - + &:after { content: ""; position: absolute; @@ -272,7 +277,7 @@ $form-field-helper-padding: 0 0.5rem !default; border-bottom: var(--rz-input-border); } } - + &:hover { .rz-form-field-content:after { border-bottom: var(--rz-input-hover-border); @@ -285,7 +290,7 @@ $form-field-helper-padding: 0 0.5rem !default; border-bottom: var(--rz-input-disabled-border); } } - + &.rz-state-focused:not(.rz-state-disabled) { .rz-form-field-content:before { inset-inline-start: calc(-1 * var(--rz-border-width)); @@ -304,7 +309,6 @@ $form-field-helper-padding: 0 0.5rem !default; .rz-numeric:focus-within .rz-numeric-button { display: block; } - } .rz-form-field-label { diff --git a/Radzen.Blazor/themes/components/blazor/_timespanpicker.scss b/Radzen.Blazor/themes/components/blazor/_timespanpicker.scss new file mode 100644 index 00000000000..74d255140c9 --- /dev/null +++ b/Radzen.Blazor/themes/components/blazor/_timespanpicker.scss @@ -0,0 +1,180 @@ +$timespanpicker-line-height: 1.25rem !default; +$timespanpicker-trigger-icon-width: var(--rz-icon-size) !default; +$timespanpicker-trigger-icon-height: $timespanpicker-trigger-icon-width !default; +$timespanpicker-trigger-icon-color: var(--rz-input-value-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-input-value-color) !default; +$timespanpicker-focus-outline: var(--rz-outline-focus) !default; +$timespanpicker-focus-outline-offset: calc(-1 * var(--rz-outline-width)) !default; +$timespanpicker-popup-border: var(--rz-border-normal) !default; +$timespanpicker-popup-shadow: 0 6px 14px 0 rgba(0, 0, 0, 0.06) !default; +$timespanpicker-popup-margin: 0 !default; +$timespanpicker-popup-width: fit-content !default; +$timespanpicker-panel-background-color: var(--rz-base-background-color) !default; +$timespanpicker-panel-text-color: var(--rz-text-color) !default; +$timespanpicker-panel-padding-block: 0.5rem !default; +$timespanpicker-panel-padding-inline: 0.5rem !default; +$timespanpicker-panel-gap: 0.5rem !default; +$timespanpicker-panel-unit-gap: 0 !default; +$timespanpicker-panel-field-min-width: 4rem !default; + +/* input field */ + +.rz-timespanpicker { + display: inline-block; + position: relative; + + .rz-readonly { + cursor: pointer; + } + + > .rz-inputtext { + @extend %input; + width: 100%; + line-height: var(--rz-timespanpicker-line-height); + } + + &:has(.rz-timespanpicker-trigger) > .rz-inputtext { + padding-inline-end: calc(1rem + var(--rz-timespanpicker-trigger-icon-width)); + } + + &:has(.rz-dropdown-clear-icon) > .rz-inputtext { + padding-inline-end: calc(1rem + var(--rz-dropdown-trigger-icon-width)); + } + + &:has(.rz-timespanpicker-trigger):has(.rz-dropdown-clear-icon) > .rz-inputtext { + padding-inline-end: calc(var(--rz-dropdown-trigger-icon-width) + 1rem + var(--rz-timespanpicker-trigger-icon-width)); + } + + &:not(.rz-state-disabled) { + &:hover { + .rz-timespanpicker-trigger { + box-shadow: none; + color: var(--rz-timespanpicker-trigger-icon-hover-color); + } + } + } + + &.rz-state-disabled { + > .rz-inputtext { + color: var(--rz-input-disabled-color); + box-shadow: var(--rz-input-disabled-shadow); + background-color: var(--rz-input-disabled-background-color); + border: var(--rz-input-disabled-border); + opacity: var(--rz-input-disabled-opacity); + + &::placeholder { + color: var(--rz-input-disabled-placeholder-color); + } + } + } + + &:not(:has(.rz-timespanpicker-trigger)) .rz-dropdown-clear-icon { + inset-inline-end: 0.5rem; + } +} + +/* popup trigger button */ + +.rz-timespanpicker-trigger { + box-shadow: none; + position: absolute; + inset-block-start: 50%; + inset-inline-end: 0.625rem; + transform: translateY(-50%); + background-color: transparent; + padding: 0; + vertical-align: text-top; + + &.rz-state-disabled { + border: none; + box-shadow: none; + cursor: initial; + opacity: 1; + color: var(--rz-input-disabled-color); + } + + color: var(--rz-timespanpicker-trigger-icon-color); + width: var(--rz-timespanpicker-trigger-icon-width); + height: var(--rz-timespanpicker-trigger-icon-height); + font-size: var(--rz-timespanpicker-trigger-icon-height); + + &:not(.rz-state-disabled) { + &:hover { + &:not(:active), + &:active { + background-color: transparent; + } + } + + &:active { + box-shadow: none !important; + background-image: none !important; + } + } + + .rzi-timespan { + font-size: inherit; + vertical-align: top; + } + + .rzi-timespan:before { + content: 'timer'; + } + + .rz-button-text { + display: none; + } +} + +/* panel */ + +.rz-timespanpicker-inline { + border: var(--rz-input-border); + border-radius: var(--rz-border-radius); + background-color: var(--rz-timespanpicker-panel-background-color); + overflow: auto; +} + +.rz-timespanpicker-popup-container { + display: none; + box-sizing: content-box; + position: absolute; + width: var(--rz-timespanpicker-popup-width); + margin: var(--rz-timespanpicker-popup-margin); + box-shadow: var(--rz-timespanpicker-popup-shadow); + border: var(--rz-timespanpicker-popup-border); + border-radius: var(--rz-border-radius); + background-color: var(--rz-timespanpicker-panel-background-color); +} + +.rz-timespanpicker-panel { + padding-block: var(--rz-timespanpicker-panel-padding-block); + padding-inline: var(--rz-timespanpicker-panel-padding-inline); + color: var(--rz-timespanpicker-panel-text-color); +} + +.rz-timespanpicker-panel, .rz-timespanpicker-panel-fieldcontainer { + display: flex; + align-items: flex-end; + justify-content: center; + gap: var(--rz-timespanpicker-panel-gap); +} + +.rz-timespanpicker-panel-fieldwithunit { + display: flex; + flex-flow: column; + flex-wrap: nowrap; + align-items: center; + gap: var(--rz-timespanpicker-panel-unit-gap); + width: min-content; +} + +.rz-timespanpicker-unitvaluepicker { + background-color: var(--rz-timespanpicker-panel-background-color); + width: fit-content; + min-width: var(--rz-timespanpicker-panel-field-min-width); +} + +.rz-timespanpicker-sign { + line-height: var(--rz-input-height); +} \ No newline at end of file diff --git a/Radzen.Blazor/themes/dark-base.scss b/Radzen.Blazor/themes/dark-base.scss index b633b02db15..b409036356a 100644 --- a/Radzen.Blazor/themes/dark-base.scss +++ b/Radzen.Blazor/themes/dark-base.scss @@ -340,6 +340,9 @@ $datepicker-header-background-color: var(--rz-base-800) !default; $datepicker-calendar-border: var(--rz-border-base-700) !default; $timepicker-background-color: var(--rz-base-900) !default; +// TimeSpanPicker +$timespanpicker-panel-background-color: var(--rz-base-800) !default; + // Upload $upload-button-bar-background-color: var(--rz-base-700) !default; $upload-files-remove-background-color: var(--rz-base-700) !default; diff --git a/Radzen.Blazor/themes/dark.scss b/Radzen.Blazor/themes/dark.scss index ac59bb0b019..a08674b2df1 100644 --- a/Radzen.Blazor/themes/dark.scss +++ b/Radzen.Blazor/themes/dark.scss @@ -340,6 +340,9 @@ $datepicker-header-background-color: var(--rz-base-800) !default; $datepicker-calendar-border: var(--rz-border-base-700) !default; $timepicker-background-color: var(--rz-base-900) !default; +// TimeSpanPicker +$timespanpicker-panel-background-color: var(--rz-base-800) !default; + // Upload $upload-button-bar-background-color: var(--rz-base-700) !default; $upload-files-remove-background-color: var(--rz-base-700) !default; diff --git a/Radzen.Blazor/themes/humanistic-dark-base.scss b/Radzen.Blazor/themes/humanistic-dark-base.scss index da55c148998..4af34065ab6 100644 --- a/Radzen.Blazor/themes/humanistic-dark-base.scss +++ b/Radzen.Blazor/themes/humanistic-dark-base.scss @@ -346,6 +346,9 @@ $datepicker-header-background-color: var(--rz-base-800) !default; $datepicker-calendar-border: var(--rz-border-base-700) !default; $timepicker-background-color: var(--rz-base-900) !default; +// TimeSpanPicker +$timespanpicker-panel-background-color: var(--rz-base-800) !default; + // Upload $upload-button-bar-background-color: var(--rz-base-700) !default; $upload-files-remove-background-color: var(--rz-base-700) !default; diff --git a/Radzen.Blazor/themes/humanistic-dark.scss b/Radzen.Blazor/themes/humanistic-dark.scss index 754c3185bee..1885c57797c 100644 --- a/Radzen.Blazor/themes/humanistic-dark.scss +++ b/Radzen.Blazor/themes/humanistic-dark.scss @@ -345,6 +345,9 @@ $datepicker-header-background-color: var(--rz-base-800) !default; $datepicker-calendar-border: var(--rz-border-base-700) !default; $timepicker-background-color: var(--rz-base-900) !default; +// TimeSpanPicker +$timespanpicker-panel-background-color: var(--rz-base-800) !default; + // Upload $upload-button-bar-background-color: var(--rz-base-700) !default; $upload-files-remove-background-color: var(--rz-base-700) !default; diff --git a/Radzen.Blazor/themes/material-base.scss b/Radzen.Blazor/themes/material-base.scss index eb1af4b877f..adaac21d1e0 100644 --- a/Radzen.Blazor/themes/material-base.scss +++ b/Radzen.Blazor/themes/material-base.scss @@ -952,6 +952,26 @@ $timepicker-border: var(--rz-border-base-300) !default; } } +// TimeSpanPicker +$timespanpicker-line-height: 1.5rem !default; +$timespanpicker-trigger-icon-color: var(--rz-text-secondary-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-popup-border: none !default; +$timespanpicker-popup-shadow: var(--rz-shadow-4) !default; +$timespanpicker-panel-padding-block: 0.75rem !default; +$timespanpicker-panel-padding-inline: 0.75rem !default; +$timespanpicker-panel-gap: 0.75rem !default; +$timespanpicker-panel-unit-gap: 0.25rem !default; +$timespanpicker-panel-field-min-width: 5rem !default; + +.rz-timespanpicker-panel { + flex-direction: column; + + > .rz-button { + align-self: stretch; + } +} + // Numeric $numeric-button-background-color: transparent !default; $numeric-button-color: var(--rz-text-color) !default; diff --git a/Radzen.Blazor/themes/material-dark-base.scss b/Radzen.Blazor/themes/material-dark-base.scss index 3b02a1a74f6..38fdbb8a7e4 100644 --- a/Radzen.Blazor/themes/material-dark-base.scss +++ b/Radzen.Blazor/themes/material-dark-base.scss @@ -977,6 +977,26 @@ $timepicker-border: var(--rz-border-base-600) !default; } } +// TimeSpanPicker +$timespanpicker-line-height: 1.5rem !default; +$timespanpicker-trigger-icon-color: var(--rz-text-secondary-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-popup-border: none !default; +$timespanpicker-popup-shadow: var(--rz-shadow-4) !default; +$timespanpicker-panel-padding-block: 0.75rem !default; +$timespanpicker-panel-padding-inline: 0.75rem !default; +$timespanpicker-panel-gap: 0.75rem !default; +$timespanpicker-panel-unit-gap: 0.25rem !default; +$timespanpicker-panel-field-min-width: 5rem !default; + +.rz-timespanpicker-panel { + flex-direction: column; + + > .rz-button { + align-self: stretch; + } +} + // Numeric $numeric-button-background-color: transparent !default; $numeric-button-color: var(--rz-text-color) !default; diff --git a/Radzen.Blazor/themes/material-dark.scss b/Radzen.Blazor/themes/material-dark.scss index 85632ac7793..a6a9b82b87a 100644 --- a/Radzen.Blazor/themes/material-dark.scss +++ b/Radzen.Blazor/themes/material-dark.scss @@ -978,6 +978,26 @@ $timepicker-border: var(--rz-border-base-600) !default; } } +// TimeSpanPicker +$timespanpicker-line-height: 1.5rem !default; +$timespanpicker-trigger-icon-color: var(--rz-text-secondary-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-popup-border: none !default; +$timespanpicker-popup-shadow: var(--rz-shadow-4) !default; +$timespanpicker-panel-padding-block: 0.75rem !default; +$timespanpicker-panel-padding-inline: 0.75rem !default; +$timespanpicker-panel-gap: 0.75rem !default; +$timespanpicker-panel-unit-gap: 0.25rem !default; +$timespanpicker-panel-field-min-width: 5rem !default; + +.rz-timespanpicker-panel { + flex-direction: column; + + > .rz-button { + align-self: stretch; + } +} + // Numeric $numeric-button-background-color: transparent !default; $numeric-button-color: var(--rz-text-color) !default; diff --git a/Radzen.Blazor/themes/material.scss b/Radzen.Blazor/themes/material.scss index 081eea28b58..3c6e2d5b3ef 100644 --- a/Radzen.Blazor/themes/material.scss +++ b/Radzen.Blazor/themes/material.scss @@ -953,6 +953,26 @@ $timepicker-border: var(--rz-border-base-300) !default; } } +// TimeSpanPicker +$timespanpicker-line-height: 1.5rem !default; +$timespanpicker-trigger-icon-color: var(--rz-text-secondary-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-popup-border: none !default; +$timespanpicker-popup-shadow: var(--rz-shadow-4) !default; +$timespanpicker-panel-padding-block: 0.75rem !default; +$timespanpicker-panel-padding-inline: 0.75rem !default; +$timespanpicker-panel-gap: 0.75rem !default; +$timespanpicker-panel-unit-gap: 0.25rem !default; +$timespanpicker-panel-field-min-width: 5rem !default; + +.rz-timespanpicker-panel { + flex-direction: column; + + > .rz-button { + align-self: stretch; + } +} + // Numeric $numeric-button-background-color: transparent !default; $numeric-button-color: var(--rz-text-color) !default; diff --git a/Radzen.Blazor/themes/software-dark-base.scss b/Radzen.Blazor/themes/software-dark-base.scss index ad3fe67ae85..753749ee974 100644 --- a/Radzen.Blazor/themes/software-dark-base.scss +++ b/Radzen.Blazor/themes/software-dark-base.scss @@ -340,6 +340,9 @@ $datepicker-header-background-color: var(--rz-base-800) !default; $datepicker-calendar-border: var(--rz-border-base-700) !default; $timepicker-background-color: var(--rz-base-900) !default; +// TimeSpanPicker +$timespanpicker-panel-background-color: var(--rz-base-800) !default; + // Upload $upload-button-bar-background-color: var(--rz-base-700) !default; $upload-files-remove-background-color: var(--rz-base-700) !default; diff --git a/Radzen.Blazor/themes/software-dark.scss b/Radzen.Blazor/themes/software-dark.scss index 2669bb38c07..f084d1c0e82 100644 --- a/Radzen.Blazor/themes/software-dark.scss +++ b/Radzen.Blazor/themes/software-dark.scss @@ -339,6 +339,9 @@ $datepicker-header-background-color: var(--rz-base-800) !default; $datepicker-calendar-border: var(--rz-border-base-700) !default; $timepicker-background-color: var(--rz-base-900) !default; +// TimeSpanPicker +$timespanpicker-panel-background-color: var(--rz-base-800) !default; + // Upload $upload-button-bar-background-color: var(--rz-base-700) !default; $upload-files-remove-background-color: var(--rz-base-700) !default; diff --git a/Radzen.Blazor/themes/standard-base.scss b/Radzen.Blazor/themes/standard-base.scss index 15d0b4ae43a..9481998a6bb 100644 --- a/Radzen.Blazor/themes/standard-base.scss +++ b/Radzen.Blazor/themes/standard-base.scss @@ -399,6 +399,11 @@ $timepicker-button-background-color: var(--rz-primary) !default; $timepicker-button-color: var(--rz-on-primary) !default; $timepicker-button-border-radius: var(--rz-border-radius) !default; +// TimeSpanPicker +$timespanpicker-trigger-icon-color: var(--rz-text-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-popup-shadow: $rz-shadow-3 !default; + // Fieldset $fieldset-border: var(--rz-border-base-300) !default; $fieldset-border-radius: var(--rz-border-radius) !default; diff --git a/Radzen.Blazor/themes/standard-dark-base.scss b/Radzen.Blazor/themes/standard-dark-base.scss index aca6bf8fb1c..765cf4f11bf 100644 --- a/Radzen.Blazor/themes/standard-dark-base.scss +++ b/Radzen.Blazor/themes/standard-dark-base.scss @@ -587,6 +587,12 @@ $timepicker-button-background-color: var(--rz-primary) !default; $timepicker-button-color: var(--rz-on-primary) !default; $timepicker-button-border-radius: var(--rz-border-radius) !default; +// TimeSpanPicker +$timespanpicker-trigger-icon-color: var(--rz-text-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-panel-background-color: var(--rz-base-background-color) !default; +$timespanpicker-popup-shadow: $rz-shadow-3 !default; + // Fieldset $fieldset-border: var(--rz-border-base-700) !default; $fieldset-border-radius: var(--rz-border-radius) !default; diff --git a/Radzen.Blazor/themes/standard-dark.scss b/Radzen.Blazor/themes/standard-dark.scss index 63f8f72a814..de141485acc 100644 --- a/Radzen.Blazor/themes/standard-dark.scss +++ b/Radzen.Blazor/themes/standard-dark.scss @@ -586,6 +586,12 @@ $timepicker-button-background-color: var(--rz-primary) !default; $timepicker-button-color: var(--rz-on-primary) !default; $timepicker-button-border-radius: var(--rz-border-radius) !default; +// TimeSpanPicker +$timespanpicker-trigger-icon-color: var(--rz-text-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-panel-background-color: var(--rz-base-background-color) !default; +$timespanpicker-popup-shadow: $rz-shadow-3 !default; + // Fieldset $fieldset-border: var(--rz-border-base-700) !default; $fieldset-border-radius: var(--rz-border-radius) !default; diff --git a/Radzen.Blazor/themes/standard.scss b/Radzen.Blazor/themes/standard.scss index 213570b3a48..dc0d7eeecd8 100644 --- a/Radzen.Blazor/themes/standard.scss +++ b/Radzen.Blazor/themes/standard.scss @@ -401,6 +401,11 @@ $timepicker-button-background-color: var(--rz-primary) !default; $timepicker-button-color: var(--rz-on-primary) !default; $timepicker-button-border-radius: var(--rz-border-radius) !default; +// TimeSpanPicker +$timespanpicker-trigger-icon-color: var(--rz-text-color) !default; +$timespanpicker-trigger-icon-hover-color: var(--rz-text-title-color) !default; +$timespanpicker-popup-shadow: $rz-shadow-3 !default; + // Fieldset $fieldset-border: var(--rz-border-base-300) !default; $fieldset-border-radius: var(--rz-border-radius) !default; diff --git a/RadzenBlazorDemos/Pages/FormFieldInputTypes.razor b/RadzenBlazorDemos/Pages/FormFieldInputTypes.razor index dc49f7b4696..e2edf083638 100644 --- a/RadzenBlazorDemos/Pages/FormFieldInputTypes.razor +++ b/RadzenBlazorDemos/Pages/FormFieldInputTypes.razor @@ -34,6 +34,9 @@ + + + @@ -65,6 +68,7 @@ string autoCompleteValue = ""; string color = "rgb(68, 58, 110)"; DateTime? date = DateTime.Today; + TimeSpan? timeSpan = new TimeSpan(5, 15, 30); IEnumerable companyNames; diff --git a/RadzenBlazorDemos/Pages/Index.razor b/RadzenBlazorDemos/Pages/Index.razor index 36f70e40d5e..11647f55b34 100644 --- a/RadzenBlazorDemos/Pages/Index.razor +++ b/RadzenBlazorDemos/Pages/Index.razor @@ -286,6 +286,7 @@ + diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerBindValue.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerBindValue.razor new file mode 100644 index 00000000000..9d43df7ca31 --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerBindValue.razor @@ -0,0 +1,8 @@ + + + + + +@code { + TimeSpan? value; +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerChangeEvent.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerChangeEvent.razor new file mode 100644 index 00000000000..15d5e785b5d --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerChangeEvent.razor @@ -0,0 +1,13 @@ + + + + + +@code { + TimeSpan? value; + + void OnChange(TimeSpan? newValue) + { + value = newValue; + } +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerConfig.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerConfig.razor new file mode 100644 index 00000000000..6fd61e36f39 --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerConfig.razor @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + TimeSpan? value = new TimeSpan(1, 12, 30, 0); + + bool allowClear = true; + bool allowInput = true; + bool readOnly = false; + bool disabled = false; + bool showPopupButton = true; + + TimeSpanUnit fieldPrecision = TimeSpanUnit.Second; + bool padTimeValues = false; + bool showConfirmationButton = false; +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerFormat.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerFormat.razor new file mode 100644 index 00000000000..89e53f4742f --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerFormat.razor @@ -0,0 +1,9 @@ + + + + + +@code { + string timeSpanFormat => (value < TimeSpan.Zero ? "'-'" : "") + "d'd 'h'h 'm'min 's's'"; + TimeSpan? value = new TimeSpan(1, 12, 30, 0); +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerInline.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerInline.razor new file mode 100644 index 00000000000..2d2964ea8dd --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerInline.razor @@ -0,0 +1,8 @@ + + +
Current value: @value
+
+ +@code { + TimeSpan value = TimeSpan.Zero; +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerMinMax.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerMinMax.razor new file mode 100644 index 00000000000..e64a118cd3d --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerMinMax.razor @@ -0,0 +1,8 @@ + + + + + +@code { + TimeSpan? value; +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerPage.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerPage.razor new file mode 100644 index 00000000000..4b6d5b939cf --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerPage.razor @@ -0,0 +1,93 @@ +@page "/timespanpicker" +@page "/docs/guides/components/timespanpicker.html" + + + TimeSpanPicker + + + Demonstration and configuration of the Radzen Blazor TimeSpanPicker component. + + + + Bind the value of TimeSpanPicker + + + As all Radzen Blazor input components, the TimeSpanPicker has a Value property which gets and sets the value of the component. Use @@bind-Value to get the user input. + + + + + + + Get and Set the value of TimeSpanPicker using Value and Change event. + + + Value property can be used to set the value of the component and Change event to get the user input. + + + + + + + Min and Max values + + + In this example, you can only set a value between 0 and 12h.
+ Note that the + and - buttons are hidden because you cannot choose a negative value. +
+ + + + + + Inline picker + + + + + + + Various configurations + + + + + + + Time span format + + + + + + + Custom input parsing + + + The ParseInput parameter allows you to use a custom input parsing method.
+ This way you can accept inputs like '30h 15min' or '-120s', or support more than one input format. +
+ + + + + + + Keyboard Navigation + + + The following keys or key combinations provide a way for users to navigate and interact with Radzen Blazor TimeSpanPicker component. + + + + +@code { + public List shortcuts = new() + { + new KeyboardShortcut { Key = "Tab", Action = "Navigate to a TimeSpanPicker component." }, + new KeyboardShortcut { Key = "Enter", Action = "Open the popup." }, + new KeyboardShortcut { Key = "Tab on open popup", Action = "Navigate forward across available picker components." }, + new KeyboardShortcut { Key = "Shift + Tab on open popup", Action = "Navigate backward across available picker components." }, + new KeyboardShortcut { Key = "Esc in an opened popup", Action = "Close the popup." } + }; +} diff --git a/RadzenBlazorDemos/Pages/TimeSpanPickerParseInput.razor b/RadzenBlazorDemos/Pages/TimeSpanPickerParseInput.razor new file mode 100644 index 00000000000..5417e176508 --- /dev/null +++ b/RadzenBlazorDemos/Pages/TimeSpanPickerParseInput.razor @@ -0,0 +1,56 @@ +@using System.Text.RegularExpressions + + + + + +@code { + TimeSpan? value; + + string[] standardFormats = { "c", "g", "G" }; + Regex customTimeSpanRegex = new Regex(@"(?:(?-?\d+)\w?d)|(?:(?-?\d+)\w?h)|(?:(?-?\d+)\w?min)|(?:(?-?\d+)\w?s)"); + + public TimeSpan? ParseTimeSpan(string input) + { + foreach (var format in standardFormats) + { + if (TimeSpan.TryParseExact(input, format, null, System.Globalization.TimeSpanStyles.None, out var standardResult)) + { + return standardResult; + } + } + + var regexGroups = customTimeSpanRegex.Matches(input.Trim()) + .Where(x => x.Success) + .SelectMany(x => x.Groups.Cast()) + .Where(x => x.Success) + .ToArray(); + + if (regexGroups.Length == 0) + { + return null; + } + + var timeUnitToValue = new Dictionary() { + {"days", 0}, + {"hours", 0}, + {"minutes", 0}, + {"seconds", 0} + }; + + foreach (var timeUnitWithValue in timeUnitToValue) + { + var unit = timeUnitWithValue.Key; + var valueString = regexGroups.FirstOrDefault(x => x.Name == unit)?.Value ?? "0"; + + if (Int32.TryParse(valueString, out int value)) + { + timeUnitToValue[unit] = value; + } + } + + var result = new TimeSpan(timeUnitToValue["days"], timeUnitToValue["hours"], timeUnitToValue["minutes"], timeUnitToValue["seconds"]); + + return result; + } +} \ No newline at end of file diff --git a/RadzenBlazorDemos/Services/ExampleService.cs b/RadzenBlazorDemos/Services/ExampleService.cs index 1f88687df2e..c824e744261 100644 --- a/RadzenBlazorDemos/Services/ExampleService.cs +++ b/RadzenBlazorDemos/Services/ExampleService.cs @@ -1372,9 +1372,9 @@ public class ExampleService Name = "DatePicker", Path = "datepicker", Updated = true, - Description = "Demonstration and configuration of the Radzen Blazor Datepicker component with calendar mode.", + Description = "Demonstration and configuration of the Radzen Blazor Datepicker component with calendar mode. Time Picker.", Icon = "", - Tags = new [] { "calendar", "form", "edit" } + Tags = new [] { "calendar", "time", "form", "edit" } }, new Example { @@ -1608,6 +1608,15 @@ public class ExampleService Tags = new [] { "input", "form", "edit" } }, new Example + { + Name = "TimeSpanPicker", + Path = "timespanpicker", + New = true, + Description = "Demonstration and configuration of the Radzen Blazor TimeSpanPicker component.", + Icon = "", + Tags = new [] { "duration", "form", "edit" } + }, + new Example { Name = "Upload", Description = "Demonstration and configuration of the Radzen Blazor Upload component.",