diff --git a/src/Components/Web/src/Forms/InputCheckbox.cs b/src/Components/Web/src/Forms/InputCheckbox.cs index f1eb030a355f..eb30ba8a3969 100644 --- a/src/Components/Web/src/Forms/InputCheckbox.cs +++ b/src/Components/Web/src/Forms/InputCheckbox.cs @@ -21,6 +21,17 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputCheckbox : InputBase { + private ElementReference? _inputElement; + + /// + /// Gets or sets the associated + /// + public ElementReference InputElement + { + get => _inputElement ?? throw new InvalidOperationException($"Component must be rendered before {nameof(InputElement)} can be accessed."); + protected set => _inputElement = value; + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -30,6 +41,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(3, "class", CssClass); builder.AddAttribute(4, "checked", BindConverter.FormatValue(CurrentValue)); builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue)); + builder.AddElementReferenceCapture(6, __inputReference => InputElement = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs index 4372646c7b15..7f24b9a5c767 100644 --- a/src/Components/Web/src/Forms/InputDate.cs +++ b/src/Components/Web/src/Forms/InputDate.cs @@ -15,12 +15,22 @@ namespace Microsoft.AspNetCore.Components.Forms public class InputDate : InputBase { private const string DateFormat = "yyyy-MM-dd"; // Compatible with HTML date inputs + private ElementReference? _inputElement; /// /// Gets or sets the error message used when displaying an a parsing error. /// [Parameter] public string ParsingErrorMessage { get; set; } = "The {0} field must be a date."; + /// + /// Gets or sets the associated + /// + public ElementReference InputElement + { + get => _inputElement ?? throw new InvalidOperationException($"Component must be rendered before {nameof(InputElement)} can be accessed."); + protected set => _inputElement = value; + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -30,6 +40,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(3, "class", CssClass); builder.AddAttribute(4, "value", BindConverter.FormatValue(CurrentValueAsString)); builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddElementReferenceCapture(6, __inputReference => InputElement = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs index 51ac2c524151..95ec963612c9 100644 --- a/src/Components/Web/src/Forms/InputNumber.cs +++ b/src/Components/Web/src/Forms/InputNumber.cs @@ -15,6 +15,7 @@ namespace Microsoft.AspNetCore.Components.Forms public class InputNumber : InputBase { private readonly static string _stepAttributeValue; // Null by default, so only allows whole numbers as per HTML spec + private ElementReference? _inputElement; static InputNumber() { @@ -41,6 +42,15 @@ static InputNumber() /// [Parameter] public string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; + /// + /// Gets or sets the associated + /// + public ElementReference InputElement + { + get => _inputElement ?? throw new InvalidOperationException($"Component must be rendered before {nameof(InputElement)} can be accessed."); + protected set => _inputElement = value; + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -51,6 +61,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(4, "class", CssClass); builder.AddAttribute(5, "value", BindConverter.FormatValue(CurrentValueAsString)); builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddElementReferenceCapture(7, __inputReference => InputElement = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index b7d5dd702552..167d9832312a 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Rendering; @@ -11,11 +12,22 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputSelect : InputBase { + private ElementReference? _selectElement; + /// /// Gets or sets the child content to be rendering inside the select element. /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// Gets or sets the associated + /// + public ElementReference SelectElement + { + get => _selectElement ?? throw new InvalidOperationException($"Component must be rendered before {nameof(SelectElement)} can be accessed."); + protected set => _selectElement = value; + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -24,7 +36,8 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValueAsString)); builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); - builder.AddContent(5, ChildContent); + builder.AddElementReferenceCapture(5, __selectReference => SelectElement = __selectReference); + builder.AddContent(6, ChildContent); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputText.cs b/src/Components/Web/src/Forms/InputText.cs index 17561eb942f8..290b32c48acf 100644 --- a/src/Components/Web/src/Forms/InputText.cs +++ b/src/Components/Web/src/Forms/InputText.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Rendering; @@ -20,6 +21,17 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputText : InputBase { + private ElementReference? _inputElement; + + /// + /// Gets or sets the associated + /// + public ElementReference InputElement + { + get => _inputElement ?? throw new InvalidOperationException($"Component must be rendered before {nameof(InputElement)} can be accessed."); + protected set => _inputElement = value; + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -28,6 +40,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValue)); builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddElementReferenceCapture(5, __inputReference => InputElement = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputTextArea.cs b/src/Components/Web/src/Forms/InputTextArea.cs index 80958270322b..169be6f25e72 100644 --- a/src/Components/Web/src/Forms/InputTextArea.cs +++ b/src/Components/Web/src/Forms/InputTextArea.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Rendering; @@ -20,6 +21,17 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputTextArea : InputBase { + private ElementReference? _inputElement; + + /// + /// Gets or sets the associated + /// + public ElementReference InputElement + { + get => _inputElement ?? throw new InvalidOperationException($"Component must be rendered before {nameof(InputElement)} can be accessed."); + protected set => _inputElement = value; + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { @@ -28,6 +40,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(2, "class", CssClass); builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValue)); builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddElementReferenceCapture(5, __inputReference => InputElement = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/test/Forms/InputDateTest.cs b/src/Components/Web/test/Forms/InputDateTest.cs index 9c6f87b8321b..f37e692f1deb 100644 --- a/src/Components/Web/test/Forms/InputDateTest.cs +++ b/src/Components/Web/test/Forms/InputDateTest.cs @@ -36,6 +36,25 @@ public async Task ValidationErrorUsesDisplayAttributeName() Assert.Contains("The Date property field must be a date.", validationMessages); } + [Fact] + public async Task InputElementIsAssignedSuccessfully() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.DateProperty, + }; + + // Act + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + var exception = Record.Exception(() => inputSelectComponent.InputElement); + + // Assert + Assert.Null(exception); + } + private class TestModel { public DateTime DateProperty { get; set; } diff --git a/src/Components/Web/test/Forms/InputNumberTest.cs b/src/Components/Web/test/Forms/InputNumberTest.cs index 6916f0e06e03..d9a9fead869c 100644 --- a/src/Components/Web/test/Forms/InputNumberTest.cs +++ b/src/Components/Web/test/Forms/InputNumberTest.cs @@ -35,6 +35,25 @@ public async Task ValidationErrorUsesDisplayAttributeName() Assert.Contains("The Some number field must be a number.", validationMessages); } + [Fact] + public async Task InputElementIsAssignedSuccessfully() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.SomeNumber, + }; + + // Act + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + var exception = Record.Exception(() => inputSelectComponent.InputElement); + + // Assert + Assert.Null(exception); + } + private class TestModel { public int SomeNumber { get; set; } diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs index 8945867fd0da..167b8e434a15 100644 --- a/src/Components/Web/test/Forms/InputSelectTest.cs +++ b/src/Components/Web/test/Forms/InputSelectTest.cs @@ -194,6 +194,25 @@ public async Task ValidationErrorUsesDisplayAttributeName() Assert.Contains("The Some number field is not valid.", validationMessages); } + [Fact] + public async Task InputElementIsAssignedSuccessfully() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.NotNullableInt, + }; + + // Act + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + var exception = Record.Exception(() => inputSelectComponent.SelectElement); + + // Assert + Assert.Null(exception); + } + enum TestEnum { One, diff --git a/src/Components/Web/test/Forms/InputTextAreaTest.cs b/src/Components/Web/test/Forms/InputTextAreaTest.cs new file mode 100644 index 000000000000..63b77bfcf799 --- /dev/null +++ b/src/Components/Web/test/Forms/InputTextAreaTest.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class InputTextAreaTest + { + [Fact] + public async Task InputElementIsAssignedSuccessfully() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + }; + + // Act + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + var exception = Record.Exception(() => inputSelectComponent.InputElement); + + // Assert + Assert.Null(exception); + } + + private class TestModel + { + public string StringProperty { get; set; } + } + } +} diff --git a/src/Components/Web/test/Forms/InputTextTest.cs b/src/Components/Web/test/Forms/InputTextTest.cs new file mode 100644 index 000000000000..583dec039816 --- /dev/null +++ b/src/Components/Web/test/Forms/InputTextTest.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class InputTextTest + { + [Fact] + public async Task InputElementIsAssignedSuccessfully() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty, + }; + + // Act + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + var exception = Record.Exception(() => inputSelectComponent.InputElement); + + // Assert + Assert.Null(exception); + } + + private class TestModel + { + public string StringProperty { get; set; } + } + } +} diff --git a/src/Components/test/E2ETest/Tests/InputActionsTest.cs b/src/Components/test/E2ETest/Tests/InputActionsTest.cs new file mode 100644 index 000000000000..d425b9dc9cc4 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/InputActionsTest.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicTestApp; +using BasicTestApp.FormsTest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests +{ + public class InputActionsTest : ServerTestBase> + { + public InputActionsTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + // On WebAssembly, page reloads are expensive so skip if possible + Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client); + } + + protected virtual IWebElement MountInputActionsComponent() + => Browser.MountTestComponent(); + + [Theory] + [InlineData("name")] + [InlineData("age")] + [InlineData("description")] + [InlineData("renewal-date")] + [InlineData("accepts-terms")] + [InlineData("ticket-class")] + public void InputElementsGetFocusedSuccessfully(string className) + { + var appElement = MountInputActionsComponent(); + var inputSection = appElement.FindElement(By.ClassName(className)); + var buttonsToFocus = inputSection.FindElements(By.TagName("button")); + var inputsToFocus = inputSection.FindElements(By.TagName("input")); + + if (inputsToFocus.Count == 0) + { + inputsToFocus = inputSection.FindElements(By.TagName("textarea")); + } + + if (inputsToFocus.Count == 0) + { + inputsToFocus = inputSection.FindElements(By.TagName("select")); + } + + for (int i = 0; i < buttonsToFocus.Count; i++) + { + buttonsToFocus[i].Click(); + Browser.Equal(inputsToFocus[i], () => Browser.SwitchTo().ActiveElement()); + } + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/InputActionsComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/InputActionsComponent.razor new file mode 100644 index 000000000000..626091bd46ea --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/InputActionsComponent.razor @@ -0,0 +1,78 @@ +@using Microsoft.AspNetCore.Components.Forms + + +

+ Name: + +

+ +

+ Age (years): + +

+ +

+ Description: + +

+ +

+ Renewal date: + +

+ +

+ Ticket class: + + + + + + + @person.TicketClass + + +

+ +

+ Accepts terms: + +

+
+ +@code { + InputText inputTextReference; + InputNumber inputNumberReference; + InputTextArea inputTextAreaReference; + InputDate inputDateReference; + InputSelect inputSelectReference; + InputCheckbox inputCheckboxReference; + + Person person = new Person(); + EditContext editContext; + + protected override void OnInitialized() + { + editContext = new EditContext(person); + } + + + // Usually this would be in a different file + class Person + { + public string Name { get; set; } + + public int AgeInYears { get; set; } + + public DateTime RenewalDate { get; set; } = DateTime.Now; + + public bool AcceptsTerms { get; set; } + + public string Description { get; set; } + + public TicketClass TicketClass { get; set; } + } + + enum TicketClass { Economy, Premium, First } +} + diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 674760191873..26abe10a6e2a 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -31,6 +31,7 @@ +