Parcourir la source

Components: Forms and validation (#7614)

Steve Sanderson il y a 7 ans
Parent
commit
7a2dfd3200
42 fichiers modifiés avec 3540 ajouts et 2 suppressions
  1. 2 0
      eng/Dependencies.props
  2. 1 0
      eng/SharedFramework.External.props
  3. 4 0
      eng/Version.Details.xml
  4. 2 0
      eng/Versions.props
  5. 5 0
      src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml
  6. 2 0
      src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
  7. 28 0
      src/Components/Components/src/Forms/DataAnnotationsValidator.cs
  8. 191 0
      src/Components/Components/src/Forms/EditContext.cs
  9. 101 0
      src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
  10. 35 0
      src/Components/Components/src/Forms/EditContextExpressionExtensions.cs
  11. 46 0
      src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs
  12. 139 0
      src/Components/Components/src/Forms/EditForm.cs
  13. 21 0
      src/Components/Components/src/Forms/FieldChangedEventArgs.cs
  14. 113 0
      src/Components/Components/src/Forms/FieldIdentifier.cs
  15. 52 0
      src/Components/Components/src/Forms/FieldState.cs
  16. 206 0
      src/Components/Components/src/Forms/InputComponents/InputBase.cs
  17. 40 0
      src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs
  18. 109 0
      src/Components/Components/src/Forms/InputComponents/InputDate.cs
  19. 162 0
      src/Components/Components/src/Forms/InputComponents/InputNumber.cs
  20. 58 0
      src/Components/Components/src/Forms/InputComponents/InputSelect.cs
  21. 44 0
      src/Components/Components/src/Forms/InputComponents/InputText.cs
  22. 44 0
      src/Components/Components/src/Forms/InputComponents/InputTextArea.cs
  23. 96 0
      src/Components/Components/src/Forms/ValidationMessage.cs
  24. 105 0
      src/Components/Components/src/Forms/ValidationMessageStore.cs
  25. 41 0
      src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs
  26. 17 0
      src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs
  27. 17 0
      src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs
  28. 93 0
      src/Components/Components/src/Forms/ValidationSummary.cs
  29. 1 0
      src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
  30. 170 0
      src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs
  31. 240 0
      src/Components/Components/test/Forms/EditContextTest.cs
  32. 198 0
      src/Components/Components/test/Forms/FieldIdentifierTest.cs
  33. 472 0
      src/Components/Components/test/Forms/InputBaseTest.cs
  34. 96 0
      src/Components/Components/test/Forms/ValidationMessageStoreTest.cs
  35. 327 0
      src/Components/test/E2ETest/Tests/FormsTest.cs
  36. 2 1
      src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj
  37. 105 0
      src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml
  38. 50 0
      src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml
  39. 89 0
      src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml
  40. 3 0
      src/Components/test/testassets/BasicTestApp/Index.cshtml
  41. 2 1
      src/Components/test/testassets/BasicTestApp/wwwroot/index.html
  42. 11 0
      src/Components/test/testassets/BasicTestApp/wwwroot/style.css

+ 2 - 0
eng/Dependencies.props

@@ -134,6 +134,8 @@ and are generated based on the last package release.
     <LatestPackageReference Include="StackExchange.Redis" Version="$(StackExchangeRedisPackageVersion)" />
     <LatestPackageReference Include="System.Buffers" Version="$(SystemBuffersPackageVersion)" />
     <LatestPackageReference Include="System.CodeDom" Version="$(SystemCodeDomPackageVersion)" />
+    <LatestPackageReference Include="System.ComponentModel" Version="$(SystemComponentModelPackageVersion)" />
+    <LatestPackageReference Include="System.ComponentModel.Annotations" Version="$(SystemComponentModelAnnotationsPackageVersion)" />
     <LatestPackageReference Include="System.Data.SqlClient" Version="$(SystemDataSqlClientPackageVersion)" />
     <LatestPackageReference Include="System.Diagnostics.EventLog" Version="$(SystemDiagnosticsEventLogPackageVersion)" />
     <LatestPackageReference Include="System.IdentityModel.Tokens.Jwt" Version="$(SystemIdentityModelTokensJwtPackageVersion)" />

+ 1 - 0
eng/SharedFramework.External.props

@@ -90,6 +90,7 @@
   -->
   <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
     <_CompilationOnlyReference Include="System.Buffers" />
+    <_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
   </ItemGroup>
 
   <!--

+ 4 - 0
eng/Version.Details.xml

@@ -293,6 +293,10 @@
       <Uri>https://github.com/dotnet/corefx</Uri>
       <Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
     </Dependency>
+    <Dependency Name="System.ComponentModel.Annotations" Version="4.6.0-preview.19109.6">
+      <Uri>https://github.com/dotnet/corefx</Uri>
+      <Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
+    </Dependency>
     <Dependency Name="System.Data.SqlClient" Version="4.7.0-preview.19109.6">
       <Uri>https://github.com/dotnet/corefx</Uri>
       <Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>

+ 2 - 0
eng/Versions.props

@@ -25,6 +25,7 @@
     <MicrosoftBclJsonSourcesPackageVersion>4.6.0-preview.19109.6</MicrosoftBclJsonSourcesPackageVersion>
     <MicrosoftCSharpPackageVersion>4.6.0-preview.19109.6</MicrosoftCSharpPackageVersion>
     <MicrosoftWin32RegistryPackageVersion>4.6.0-preview.19109.6</MicrosoftWin32RegistryPackageVersion>
+    <SystemComponentModelAnnotationsPackageVersion>4.6.0-preview.19109.6</SystemComponentModelAnnotationsPackageVersion>
     <SystemDataSqlClientPackageVersion>4.7.0-preview.19109.6</SystemDataSqlClientPackageVersion>
     <SystemDiagnosticsEventLogPackageVersion>4.6.0-preview.19109.6</SystemDiagnosticsEventLogPackageVersion>
     <SystemIOPipelinesPackageVersion>4.6.0-preview.19109.6</SystemIOPipelinesPackageVersion>
@@ -134,6 +135,7 @@
     <!-- Stable dotnet/corefx packages no longer updated for .NET Core 3 -->
     <SystemBuffersPackageVersion>4.5.0</SystemBuffersPackageVersion>
     <SystemCodeDomPackageVersion>4.4.0</SystemCodeDomPackageVersion>
+    <SystemComponentModelPackageVersion>4.3.0</SystemComponentModelPackageVersion>
     <SystemNetHttpPackageVersion>4.3.2</SystemNetHttpPackageVersion>
     <SystemThreadingTasksExtensionsPackageVersion>4.5.2</SystemThreadingTasksExtensionsPackageVersion>
     <!-- Packages developed by @aspnet, but manually updated as necessary. -->

+ 5 - 0
src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml

@@ -9,4 +9,9 @@
     to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 -->
     <type fullname="System.Threading.WasmRuntime" />
   </assembly>
+
+  <assembly fullname="System">
+    <!-- Without this, [Required(typeof(bool), "true", "true", ErrorMessage = "...")] fails -->
+    <type fullname="System.ComponentModel.BooleanConverter" />
+  </assembly>
 </linker>

+ 2 - 0
src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs

@@ -84,6 +84,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
                 "System.Collections.dll",
                 "System.ComponentModel.Composition.dll",
                 "System.ComponentModel.dll",
+                "System.ComponentModel.Annotations.dll",
+                "System.ComponentModel.DataAnnotations.dll",
                 "System.Core.dll",
                 "System.Data.dll",
                 "System.Diagnostics.Debug.dll",

+ 28 - 0
src/Components/Components/src/Forms/DataAnnotationsValidator.cs

@@ -0,0 +1,28 @@
+// 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;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Adds Data Annotations validation support to an <see cref="EditContext"/>.
+    /// </summary>
+    public class DataAnnotationsValidator : ComponentBase
+    {
+        [CascadingParameter] EditContext CurrentEditContext { get; set; }
+
+        /// <inheritdoc />
+        protected override void OnInit()
+        {
+            if (CurrentEditContext == null)
+            {
+                throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " +
+                    $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " +
+                    $"inside an {nameof(EditForm)}.");
+            }
+
+            CurrentEditContext.AddDataAnnotationsValidation();
+        }
+    }
+}

+ 191 - 0
src/Components/Components/src/Forms/EditContext.cs

@@ -0,0 +1,191 @@
+// 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.Collections.Generic;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Holds metadata related to a data editing process, such as flags to indicate which
+    /// fields have been modified and the current set of validation messages.
+    /// </summary>
+    public sealed class EditContext
+    {
+        // Note that EditContext tracks state for any FieldIdentifier you give to it, plus
+        // the underlying storage is sparse. As such, none of the APIs have a "field not found"
+        // error state. If you give us an unrecognized FieldIdentifier, that just means we
+        // didn't yet track any state for it, so we behave as if it's in the default state
+        // (valid and unmodified).
+        private readonly Dictionary<FieldIdentifier, FieldState> _fieldStates = new Dictionary<FieldIdentifier, FieldState>();
+
+        /// <summary>
+        /// Constructs an instance of <see cref="EditContext"/>.
+        /// </summary>
+        /// <param name="model">The model object for the <see cref="EditContext"/>. This object should hold the data being edited, for example as a set of properties.</param>
+        public EditContext(object model)
+        {
+            // The only reason we disallow null is because you'd almost always want one, and if you
+            // really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
+            // simplifies things for all consumers of EditContext.
+            Model = model ?? throw new ArgumentNullException(nameof(model));
+        }
+
+        /// <summary>
+        /// An event that is raised when a field value changes.
+        /// </summary>
+        public event EventHandler<FieldChangedEventArgs> OnFieldChanged;
+
+        /// <summary>
+        /// An event that is raised when validation is requested.
+        /// </summary>
+        public event EventHandler<ValidationRequestedEventArgs> OnValidationRequested;
+
+        /// <summary>
+        /// An event that is raised when validation state has changed.
+        /// </summary>
+        public event EventHandler<ValidationStateChangedEventArgs> OnValidationStateChanged;
+
+        /// <summary>
+        /// Supplies a <see cref="FieldIdentifier"/> corresponding to a specified field name
+        /// on this <see cref="EditContext"/>'s <see cref="Model"/>.
+        /// </summary>
+        /// <param name="fieldName">The name of the editable field.</param>
+        /// <returns>A <see cref="FieldIdentifier"/> corresponding to a specified field name on this <see cref="EditContext"/>'s <see cref="Model"/>.</returns>
+        public FieldIdentifier Field(string fieldName)
+            => new FieldIdentifier(Model, fieldName);
+
+        /// <summary>
+        /// Gets the model object for this <see cref="EditContext"/>.
+        /// </summary>
+        public object Model { get; }
+
+        /// <summary>
+        /// Signals that the value for the specified field has changed.
+        /// </summary>
+        /// <param name="fieldIdentifier">Identifies the field whose value has been changed.</param>
+        public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
+        {
+            GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true;
+            OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
+        }
+
+        /// <summary>
+        /// Signals that some aspect of validation state has changed.
+        /// </summary>
+        public void NotifyValidationStateChanged()
+        {
+            OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
+        }
+
+        /// <summary>
+        /// Clears any modification flag that may be tracked for the specified field.
+        /// </summary>
+        /// <param name="fieldIdentifier">Identifies the field whose modification flag (if any) should be cleared.</param>
+        public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier)
+        {
+            if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
+            {
+                state.IsModified = false;
+            }
+        }
+
+        /// <summary>
+        /// Clears all modification flags within this <see cref="EditContext"/>.
+        /// </summary>
+        public void MarkAsUnmodified()
+        {
+            foreach (var state in _fieldStates)
+            {
+                state.Value.IsModified = false;
+            }
+        }
+
+        /// <summary>
+        /// Determines whether any of the fields in this <see cref="EditContext"/> have been modified.
+        /// </summary>
+        /// <returns>True if any of the fields in this <see cref="EditContext"/> have been modified; otherwise false.</returns>
+        public bool IsModified()
+        {
+            // If necessary, we could consider caching the overall "is modified" state and only recomputing
+            // when there's a call to NotifyFieldModified/NotifyFieldUnmodified
+            foreach (var state in _fieldStates)
+            {
+                if (state.Value.IsModified)
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Gets the current validation messages across all fields.
+        ///
+        /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+        /// </summary>
+        /// <returns>The current validation messages.</returns>
+        public IEnumerable<string> GetValidationMessages()
+        {
+            // Since we're only enumerating the fields for which we have a non-null state, the cost of this grows
+            // based on how many fields have been modified or have associated validation messages
+            foreach (var state in _fieldStates)
+            {
+                foreach (var message in state.Value.GetValidationMessages())
+                {
+                    yield return message;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets the current validation messages for the specified field.
+        ///
+        /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+        /// </summary>
+        /// <param name="fieldIdentifier">Identifies the field whose current validation messages should be returned.</param>
+        /// <returns>The current validation messages for the specified field.</returns>
+        public IEnumerable<string> GetValidationMessages(FieldIdentifier fieldIdentifier)
+        {
+            if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
+            {
+                foreach (var message in state.GetValidationMessages())
+                {
+                    yield return message;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
+        /// </summary>
+        /// <returns>True if the field has been modified; otherwise false.</returns>
+        public bool IsModified(in FieldIdentifier fieldIdentifier)
+            => _fieldStates.TryGetValue(fieldIdentifier, out var state)
+            ? state.IsModified
+            : false;
+
+        /// <summary>
+        /// Validates this <see cref="EditContext"/>.
+        /// </summary>
+        /// <returns>True if there are no validation messages after validation; otherwise false.</returns>
+        public bool Validate()
+        {
+            OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty);
+            return !GetValidationMessages().Any();
+        }
+
+        internal FieldState GetFieldState(in FieldIdentifier fieldIdentifier, bool ensureExists)
+        {
+            if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists)
+            {
+                state = new FieldState(fieldIdentifier);
+                _fieldStates.Add(fieldIdentifier, state);
+            }
+
+            return state;
+        }
+    }
+}

+ 101 - 0
src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs

@@ -0,0 +1,101 @@
+// 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.Collections.Concurrent;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Reflection;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Extension methods to add DataAnnotations validation to an <see cref="EditContext"/>.
+    /// </summary>
+    public static class EditContextDataAnnotationsExtensions
+    {
+        private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache
+            = new ConcurrentDictionary<(Type, string), PropertyInfo>();
+
+        /// <summary>
+        /// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
+        /// </summary>
+        /// <param name="editContext">The <see cref="EditContext"/>.</param>
+        public static EditContext AddDataAnnotationsValidation(this EditContext editContext)
+        {
+            if (editContext == null)
+            {
+                throw new ArgumentNullException(nameof(editContext));
+            }
+
+            var messages = new ValidationMessageStore(editContext);
+
+            // Perform object-level validation on request
+            editContext.OnValidationRequested +=
+                (sender, eventArgs) => ValidateModel((EditContext)sender, messages);
+
+            // Perform per-field validation on each field edit
+            editContext.OnFieldChanged +=
+                (sender, eventArgs) => ValidateField(editContext, messages, eventArgs.FieldIdentifier);
+
+            return editContext;
+        }
+
+        private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
+        {
+            var validationContext = new ValidationContext(editContext.Model);
+            var validationResults = new List<ValidationResult>();
+            Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
+
+            // Transfer results to the ValidationMessageStore
+            messages.Clear();
+            foreach (var validationResult in validationResults)
+            {
+                foreach (var memberName in validationResult.MemberNames)
+                {
+                    messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
+                }
+            }
+
+            editContext.NotifyValidationStateChanged();
+        }
+
+        private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
+        {
+            if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
+            {
+                var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
+                var validationContext = new ValidationContext(fieldIdentifier.Model)
+                {
+                    MemberName = propertyInfo.Name
+                };
+                var results = new List<ValidationResult>();
+
+                Validator.TryValidateProperty(propertyValue, validationContext, results);
+                messages.Clear(fieldIdentifier);
+                messages.AddRange(fieldIdentifier, results.Select(result => result.ErrorMessage));
+
+                // We have to notify even if there were no messages before and are still no messages now,
+                // because the "state" that changed might be the completion of some async validation task
+                editContext.NotifyValidationStateChanged();
+            }
+        }
+
+        private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo)
+        {
+            var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
+            if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
+            {
+                // DataAnnotations only validates public properties, so that's all we'll look for
+                // If we can't find it, cache 'null' so we don't have to try again next time
+                propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
+
+                // No need to lock, because it doesn't matter if we write the same value twice
+                _propertyInfoCache[cacheKey] = propertyInfo;
+            }
+
+            return propertyInfo != null;
+        }
+    }
+}

+ 35 - 0
src/Components/Components/src/Forms/EditContextExpressionExtensions.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;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Provides extension methods to simplify using <see cref="EditContext"/> with expressions.
+    /// </summary>
+    public static class EditContextExpressionExtensions
+    {
+        /// <summary>
+        /// Gets the current validation messages for the specified field.
+        ///
+        /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+        /// </summary>
+        /// <param name="editContext">The <see cref="EditContext"/>.</param>
+        /// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
+        /// <returns>The current validation messages for the specified field.</returns>
+        public static IEnumerable<string> GetValidationMessages(this EditContext editContext, Expression<Func<object>> accessor)
+            => editContext.GetValidationMessages(FieldIdentifier.Create(accessor));
+
+        /// <summary>
+        /// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
+        /// </summary>
+        /// <param name="editContext">The <see cref="EditContext"/>.</param>
+        /// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
+        /// <returns>True if the field has been modified; otherwise false.</returns>
+        public static bool IsModified(this EditContext editContext, Expression<Func<object>> accessor)
+            => editContext.IsModified(FieldIdentifier.Create(accessor));
+    }
+}

+ 46 - 0
src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs

@@ -0,0 +1,46 @@
+// 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.Linq;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Provides extension methods to describe the state of <see cref="EditContext"/>
+    /// fields as CSS class names.
+    /// </summary>
+    public static class EditContextFieldClassExtensions
+    {
+        /// <summary>
+        /// Gets a string that indicates the status of the specified field. This will include
+        /// some combination of "modified", "valid", or "invalid", depending on the status of the field.
+        /// </summary>
+        /// <param name="editContext">The <see cref="EditContext"/>.</param>
+        /// <param name="accessor">An identifier for the field.</param>
+        /// <returns>A string that indicates the status of the field.</returns>
+        public static string FieldClass<TField>(this EditContext editContext, Expression<Func<TField>> accessor)
+            => FieldClass(editContext, FieldIdentifier.Create(accessor));
+
+        /// <summary>
+        /// Gets a string that indicates the status of the specified field. This will include
+        /// some combination of "modified", "valid", or "invalid", depending on the status of the field.
+        /// </summary>
+        /// <param name="editContext">The <see cref="EditContext"/>.</param>
+        /// <param name="fieldIdentifier">An identifier for the field.</param>
+        /// <returns>A string that indicates the status of the field.</returns>
+        public static string FieldClass(this EditContext editContext, in FieldIdentifier fieldIdentifier)
+        {
+            var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any();
+            if (editContext.IsModified(fieldIdentifier))
+            {
+                return isValid ? "modified valid" : "modified invalid";
+            }
+            else
+            {
+                return isValid ? "valid" : "invalid";
+            }
+        }
+    }
+}

+ 139 - 0
src/Components/Components/src/Forms/EditForm.cs

@@ -0,0 +1,139 @@
+// 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.Threading.Tasks;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Renders a form element that cascades an <see cref="EditContext"/> to descendants.
+    /// </summary>
+    public class EditForm : ComponentBase
+    {
+        private readonly Func<Task> _handleSubmitDelegate; // Cache to avoid per-render allocations
+
+        private EditContext _fixedEditContext;
+
+        /// <summary>
+        /// Constructs an instance of <see cref="EditForm"/>.
+        /// </summary>
+        public EditForm()
+        {
+            _handleSubmitDelegate = HandleSubmitAsync;
+        }
+
+        /// <summary>
+        /// Supplies the edit context explicitly. If using this parameter, do not
+        /// also supply <see cref="Model"/>, since the model value will be taken
+        /// from the <see cref="EditContext.Model"/> property.
+        /// </summary>
+        [Parameter] EditContext EditContext { get; set; }
+
+        /// <summary>
+        /// Specifies the top-level model object for the form. An edit context will
+        /// be constructed for this model. If using this parameter, do not also supply
+        /// a value for <see cref="EditContext"/>.
+        /// </summary>
+        [Parameter] object Model { get; set; }
+
+        /// <summary>
+        /// Specifies the content to be rendered inside this <see cref="EditForm"/>.
+        /// </summary>
+        [Parameter] RenderFragment<EditContext> ChildContent { get; set; }
+
+        /// <summary>
+        /// A callback that will be invoked when the form is submitted.
+        ///
+        /// If using this parameter, you are responsible for triggering any validation
+        /// manually, e.g., by calling <see cref="EditContext.Validate"/>.
+        /// </summary>
+        [Parameter] EventCallback<EditContext> OnSubmit { get; set; }
+
+        /// <summary>
+        /// A callback that will be invoked when the form is submitted and the
+        /// <see cref="EditContext"/> is determined to be valid.
+        /// </summary>
+        [Parameter] EventCallback<EditContext> OnValidSubmit { get; set; }
+
+        /// <summary>
+        /// A callback that will be invoked when the form is submitted and the
+        /// <see cref="EditContext"/> is determined to be invalid.
+        /// </summary>
+        [Parameter] EventCallback<EditContext> OnInvalidSubmit { get; set; }
+
+        /// <inheritdoc />
+        protected override void OnParametersSet()
+        {
+            if ((EditContext == null) == (Model == null))
+            {
+                throw new InvalidOperationException($"{nameof(EditForm)} requires a {nameof(Model)} " +
+                    $"parameter, or an {nameof(EditContext)} parameter, but not both.");
+            }
+
+            // If you're using OnSubmit, it becomes your responsibility to trigger validation manually
+            // (e.g., so you can display a "pending" state in the UI). In that case you don't want the
+            // system to trigger a second validation implicitly, so don't combine it with the simplified
+            // OnValidSubmit/OnInvalidSubmit handlers.
+            if (OnSubmit.HasDelegate && (OnValidSubmit.HasDelegate || OnInvalidSubmit.HasDelegate))
+            {
+                throw new InvalidOperationException($"When supplying an {nameof(OnSubmit)} parameter to " +
+                    $"{nameof(EditForm)}, do not also supply {nameof(OnValidSubmit)} or {nameof(OnInvalidSubmit)}.");
+            }
+
+            // Update _fixedEditContext if we don't have one yet, or if they are supplying a
+            // potentially new EditContext, or if they are supplying a different Model
+            if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model)
+            {
+                _fixedEditContext = EditContext ?? new EditContext(Model);
+            }
+        }
+
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+
+            // If _fixedEditContext changes, tear down and recreate all descendants.
+            // This is so we can safely use the IsFixed optimization on CascadingValue,
+            // optimizing for the common case where _fixedEditContext never changes.
+            builder.OpenRegion(_fixedEditContext.GetHashCode());
+
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", _handleSubmitDelegate);
+            builder.OpenComponent<CascadingValue<EditContext>>(2);
+            builder.AddAttribute(3, "IsFixed", true);
+            builder.AddAttribute(4, "Value", _fixedEditContext);
+            builder.AddAttribute(5, RenderTreeBuilder.ChildContent, ChildContent?.Invoke(_fixedEditContext));
+            builder.CloseComponent();
+            builder.CloseElement();
+
+            builder.CloseRegion();
+        }
+
+        private async Task HandleSubmitAsync()
+        {
+            if (OnSubmit.HasDelegate)
+            {
+                // When using OnSubmit, the developer takes control of the validation lifecycle
+                await OnSubmit.InvokeAsync(_fixedEditContext);
+            }
+            else
+            {
+                // Otherwise, the system implicitly runs validation on form submission
+                var isValid = _fixedEditContext.Validate(); // This will likely become ValidateAsync later
+
+                if (isValid && OnValidSubmit.HasDelegate)
+                {
+                    await OnValidSubmit.InvokeAsync(_fixedEditContext);
+                }
+
+                if (!isValid && OnInvalidSubmit.HasDelegate)
+                {
+                    await OnInvalidSubmit.InvokeAsync(_fixedEditContext);
+                }
+            }
+        }
+    }
+}

+ 21 - 0
src/Components/Components/src/Forms/FieldChangedEventArgs.cs

@@ -0,0 +1,21 @@
+// 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.
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Provides information about the <see cref="EditContext.OnFieldChanged"/> event.
+    /// </summary>
+    public sealed class FieldChangedEventArgs
+    {
+        /// <summary>
+        /// Identifies the field whose value has changed.
+        /// </summary>
+        public FieldIdentifier FieldIdentifier { get; }
+
+        internal FieldChangedEventArgs(in FieldIdentifier fieldIdentifier)
+        {
+            FieldIdentifier = fieldIdentifier;
+        }
+    }
+}

+ 113 - 0
src/Components/Components/src/Forms/FieldIdentifier.cs

@@ -0,0 +1,113 @@
+// 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.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Uniquely identifies a single field that can be edited. This may correspond to a property on a
+    /// model object, or can be any other named value.
+    /// </summary>
+    public readonly struct FieldIdentifier
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="FieldIdentifier"/> structure.
+        /// </summary>
+        /// <param name="accessor">An expression that identifies an object member.</param>
+        public static FieldIdentifier Create<T>(Expression<Func<T>> accessor)
+        {
+            if (accessor == null)
+            {
+                throw new ArgumentNullException(nameof(accessor));
+            }
+
+            ParseAccessor(accessor, out var model, out var fieldName);
+            return new FieldIdentifier(model, fieldName);
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="FieldIdentifier"/> structure.
+        /// </summary>
+        /// <param name="model">The object that owns the field.</param>
+        /// <param name="fieldName">The name of the editable field.</param>
+        public FieldIdentifier(object model, string fieldName)
+        {
+            if (model == null)
+            {
+                throw new ArgumentNullException(nameof(model));
+            }
+
+            if (model.GetType().IsValueType)
+            {
+                throw new ArgumentException("The model must be a reference-typed object.", nameof(model));
+            }
+
+            Model = model;
+
+            // Note that we do allow an empty string. This is used by some validation systems
+            // as a place to store object-level (not per-property) messages.
+            FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
+        }
+
+        /// <summary>
+        /// Gets the object that owns the editable field.
+        /// </summary>
+        public object Model { get; }
+
+        /// <summary>
+        /// Gets the name of the editable field.
+        /// </summary>
+        public string FieldName { get; }
+
+        /// <inheritdoc />
+        public override int GetHashCode()
+            => (Model, FieldName).GetHashCode();
+
+        /// <inheritdoc />
+        public override bool Equals(object obj)
+            => obj is FieldIdentifier otherIdentifier
+            && otherIdentifier.Model == Model
+            && string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
+
+        private static void ParseAccessor<T>(Expression<Func<T>> accessor, out object model, out string fieldName)
+        {
+            var accessorBody = accessor.Body;
+
+            // Unwrap casts to object
+            if (accessorBody is UnaryExpression unaryExpression
+                && unaryExpression.NodeType == ExpressionType.Convert
+                && unaryExpression.Type == typeof(object))
+            {
+                accessorBody = unaryExpression.Operand;
+            }
+
+            if (!(accessorBody is MemberExpression memberExpression))
+            {
+                throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.");
+            }
+
+            // Identify the field name. We don't mind whether it's a property or field, or even something else.
+            fieldName = memberExpression.Member.Name;
+
+            // Get a reference to the model object
+            // i.e., given an value like "(something).MemberName", determine the runtime value of "(something)",
+            switch (memberExpression.Expression)
+            {
+                case ConstantExpression constantExpression:
+                    model = constantExpression.Value;
+                    break;
+                default:
+                    // It would be great to cache this somehow, but it's unclear there's a reasonable way to do
+                    // so, given that it embeds captured values such as "this". We could consider special-casing
+                    // for "() => something.Member" and building a cache keyed by "something.GetType()" with values
+                    // of type Func<object, object> so we can cheaply map from "something" to "something.Member".
+                    var modelLambda = Expression.Lambda(memberExpression.Expression);
+                    var modelLambdaCompiled = (Func<object>)modelLambda.Compile();
+                    model = modelLambdaCompiled();
+                    break;
+            }
+        }
+    }
+}

+ 52 - 0
src/Components/Components/src/Forms/FieldState.cs

@@ -0,0 +1,52 @@
+// 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.Collections.Generic;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    internal class FieldState
+    {
+        private readonly FieldIdentifier _fieldIdentifier;
+
+        // We track which ValidationMessageStore instances have a nonempty set of messages for this field so that
+        // we can quickly evaluate the list of messages for the field without having to query all stores. This is
+        // relevant because each validation component may define its own message store, so there might be as many
+        // stores are there are fields or UI elements.
+        private HashSet<ValidationMessageStore> _validationMessageStores;
+
+        public FieldState(FieldIdentifier fieldIdentifier)
+        {
+            _fieldIdentifier = fieldIdentifier;
+        }
+
+        public bool IsModified { get; set; }
+
+        public IEnumerable<string> GetValidationMessages()
+        {
+            if (_validationMessageStores != null)
+            {
+                foreach (var store in _validationMessageStores)
+                {
+                    foreach (var message in store[_fieldIdentifier])
+                    {
+                        yield return message;
+                    }
+                }
+            }
+        }
+
+        public void AssociateWithValidationMessageStore(ValidationMessageStore validationMessageStore)
+        {
+            if (_validationMessageStores == null)
+            {
+                _validationMessageStores = new HashSet<ValidationMessageStore>();
+            }
+
+            _validationMessageStores.Add(validationMessageStore);
+        }
+
+        public void DissociateFromValidationMessageStore(ValidationMessageStore validationMessageStore)
+            => _validationMessageStores?.Remove(validationMessageStore);
+    }
+}

+ 206 - 0
src/Components/Components/src/Forms/InputComponents/InputBase.cs

@@ -0,0 +1,206 @@
+// 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.Collections.Generic;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// A base class for form input components. This base class automatically
+    /// integrates with an <see cref="Forms.EditContext"/>, which must be supplied
+    /// as a cascading parameter.
+    /// </summary>
+    public abstract class InputBase<T> : ComponentBase
+    {
+        private bool _previousParsingAttemptFailed;
+        private ValidationMessageStore _parsingValidationMessages;
+        private Type _nullableUnderlyingType;
+
+        [CascadingParameter] EditContext CascadedEditContext { get; set; }
+
+        /// <summary>
+        /// Gets a value for the component's 'id' attribute.
+        /// </summary>
+        [Parameter] protected string Id { get; private set; }
+
+        /// <summary>
+        /// Gets a value for the component's 'class' attribute.
+        /// </summary>
+        [Parameter] protected string Class { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the value of the input. This should be used with two-way binding.
+        /// </summary>
+        /// <example>
+        /// bind-Value="@model.PropertyName"
+        /// </example>
+        [Parameter] T Value { get; set; }
+
+        /// <summary>
+        /// Gets or sets a callback that updates the bound value.
+        /// </summary>
+        [Parameter] Action<T> ValueChanged { get; set; }
+
+        /// <summary>
+        /// Gets or sets an expression that identifies the bound value.
+        /// </summary>
+        [Parameter] Expression<Func<T>> ValueExpression { get; set; }
+
+        /// <summary>
+        /// Gets the associated <see cref="Microsoft.AspNetCore.Components.Forms.EditContext"/>.
+        /// </summary>
+        protected EditContext EditContext { get; private set; }
+
+        /// <summary>
+        /// Gets the <see cref="FieldIdentifier"/> for the bound value.
+        /// </summary>
+        protected FieldIdentifier FieldIdentifier { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the current value of the input.
+        /// </summary>
+        protected T CurrentValue
+        {
+            get => Value;
+            set
+            {
+                var hasChanged = !EqualityComparer<T>.Default.Equals(value, Value);
+                if (hasChanged)
+                {
+                    Value = value;
+                    ValueChanged?.Invoke(value);
+                    EditContext.NotifyFieldChanged(FieldIdentifier);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the current value of the input, represented as a string.
+        /// </summary>
+        protected string CurrentValueAsString
+        {
+            get => FormatValueAsString(CurrentValue);
+            set
+            {
+                _parsingValidationMessages?.Clear();
+
+                bool parsingFailed;
+
+                if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value))
+                {
+                    // Assume if it's a nullable type, null/empty inputs should correspond to default(T)
+                    // Then all subclasses get nullable support almost automatically (they just have to
+                    // not reject Nullable<T> based on the type itself).
+                    parsingFailed = false;
+                    CurrentValue = default;
+                }
+                else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage))
+                {
+                    parsingFailed = false;
+                    CurrentValue = parsedValue;
+                }
+                else
+                {
+                    parsingFailed = true;
+
+                    if (_parsingValidationMessages == null)
+                    {
+                        _parsingValidationMessages = new ValidationMessageStore(EditContext);
+                    }
+
+                    _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);
+
+                    // Since we're not writing to CurrentValue, we'll need to notify about modification from here
+                    EditContext.NotifyFieldChanged(FieldIdentifier);
+                }
+
+                // We can skip the validation notification if we were previously valid and still are
+                if (parsingFailed || _previousParsingAttemptFailed)
+                {
+                    EditContext.NotifyValidationStateChanged();
+                    _previousParsingAttemptFailed = parsingFailed;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Formats the value as a string. Derived classes can override this to determine the formating used for <see cref="CurrentValueAsString"/>.
+        /// </summary>
+        /// <param name="value">The value to format.</param>
+        /// <returns>A string representation of the value.</returns>
+        protected virtual string FormatValueAsString(T value)
+            => value?.ToString();
+
+        /// <summary>
+        /// Parses a string to create an instance of <typeparamref name="T"/>. Derived classes can override this to change how
+        /// <see cref="CurrentValueAsString"/> interprets incoming values.
+        /// </summary>
+        /// <param name="value">The string value to be parsed.</param>
+        /// <param name="result">An instance of <typeparamref name="T"/>.</param>
+        /// <param name="validationErrorMessage">If the value could not be parsed, provides a validation error message.</param>
+        /// <returns>True if the value could be parsed; otherwise false.</returns>
+        protected abstract bool TryParseValueFromString(string value, out T result, out string validationErrorMessage);
+
+        /// <summary>
+        /// Gets a string that indicates the status of the field being edited. This will include
+        /// some combination of "modified", "valid", or "invalid", depending on the status of the field.
+        /// </summary>
+        protected string FieldClass
+            => EditContext.FieldClass(FieldIdentifier);
+
+        /// <summary>
+        /// Gets a CSS class string that combines the <see cref="Class"/> and <see cref="FieldClass"/>
+        /// properties. Derived components should typically use this value for the primary HTML element's
+        /// 'class' attribute.
+        /// </summary>
+        protected string CssClass
+            => string.IsNullOrEmpty(Class)
+            ? FieldClass // Never null or empty
+            : $"{Class} {FieldClass}";
+
+        /// <inheritdoc />
+        public override Task SetParametersAsync(ParameterCollection parameters)
+        {
+            parameters.SetParameterProperties(this);
+
+            if (EditContext == null)
+            {
+                // This is the first run
+                // Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit()
+
+                if (CascadedEditContext == null)
+                {
+                    throw new InvalidOperationException($"{GetType()} requires a cascading parameter " +
+                        $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " +
+                        $"an {nameof(EditForm)}.");
+                }
+
+                if (ValueExpression == null)
+                {
+                    throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " +
+                        $"parameter. Normally this is provided automatically when using 'bind-Value'.");
+                }
+
+                EditContext = CascadedEditContext;
+                FieldIdentifier = FieldIdentifier.Create(ValueExpression);
+                _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(T));
+            }
+            else if (CascadedEditContext != EditContext)
+            {
+                // Not the first run
+
+                // We don't support changing EditContext because it's messy to be clearing up state and event
+                // handlers for the previous one, and there's no strong use case. If a strong use case
+                // emerges, we can consider changing this.
+                throw new InvalidOperationException($"{GetType()} does not support changing the " +
+                    $"{nameof(Forms.EditContext)} dynamically.");
+            }
+
+            // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc.
+            return base.SetParametersAsync(ParameterCollection.Empty);
+        }
+    }
+}

+ 40 - 0
src/Components/Components/src/Forms/InputComponents/InputCheckbox.cs

@@ -0,0 +1,40 @@
+// 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 Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /* This is exactly equivalent to a .razor file containing:
+     *
+     *    @inherits InputBase<bool>
+     *    <input type="checkbox" bind="@CurrentValue" id="@Id" class="@CssClass" />
+     *
+     * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those
+     * files within this project. Developers building their own input components should use Razor syntax.
+     */
+
+    /// <summary>
+    /// An input component for editing <see cref="bool"/> values.
+    /// </summary>
+    public class InputCheckbox : InputBase<bool>
+    {
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+            builder.OpenElement(0, "input");
+            builder.AddAttribute(1, "type", "checkbox");
+            builder.AddAttribute(2, "id", Id);
+            builder.AddAttribute(3, "class", CssClass);
+            builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValue));
+            builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue));
+            builder.CloseElement();
+        }
+
+        /// <inheritdoc />
+        protected override bool TryParseValueFromString(string value, out bool result, out string validationErrorMessage)
+            => throw new NotImplementedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'.");
+    }
+}

+ 109 - 0
src/Components/Components/src/Forms/InputComponents/InputDate.cs

@@ -0,0 +1,109 @@
+// 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 Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// An input component for editing date values.
+    /// Supported types are <see cref="DateTime"/> and <see cref="DateTimeOffset"/>.
+    /// </summary>
+    public class InputDate<T> : InputBase<T>
+    {
+        const string dateFormat = "yyyy-MM-dd"; // Compatible with HTML date inputs
+
+        [Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a date.";
+
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+            builder.OpenElement(0, "input");
+            builder.AddAttribute(1, "type", "date");
+            builder.AddAttribute(2, "id", Id);
+            builder.AddAttribute(3, "class", CssClass);
+            builder.AddAttribute(4, "value", BindMethods.GetValue(CurrentValueAsString));
+            builder.AddAttribute(5, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString));
+            builder.CloseElement();
+        }
+
+        /// <inheritdoc />
+        protected override string FormatValueAsString(T value)
+        {
+            switch (value)
+            {
+                case DateTime dateTimeValue:
+                    return dateTimeValue.ToString(dateFormat);
+                case DateTimeOffset dateTimeOffsetValue:
+                    return dateTimeOffsetValue.ToString(dateFormat);
+                default:
+                    return string.Empty; // Handles null for Nullable<DateTime>, etc.
+            }
+        }
+
+        /// <inheritdoc />
+        protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage)
+        {
+            // Unwrap nullable types. We don't have to deal with receiving empty values for nullable
+            // types here, because the underlying InputBase already covers that.
+            var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
+
+            bool success;
+            if (targetType == typeof(DateTime))
+            {
+                success = TryParseDateTime(value, out result);
+            }
+            else if (targetType == typeof(DateTimeOffset))
+            {
+                success = TryParseDateTimeOffset(value, out result);
+            }
+            else
+            {
+                throw new InvalidOperationException($"The type '{targetType}' is not a supported date type.");
+            }
+
+            if (success)
+            {
+                validationErrorMessage = null;
+                return true;
+            }
+            else
+            {
+                validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName);
+                return false;
+            }
+        }
+
+        static bool TryParseDateTime(string value, out T result)
+        {
+            var success = DateTime.TryParse(value, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+
+        static bool TryParseDateTimeOffset(string value, out T result)
+        {
+            var success = DateTimeOffset.TryParse(value, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+    }
+}

+ 162 - 0
src/Components/Components/src/Forms/InputComponents/InputNumber.cs

@@ -0,0 +1,162 @@
+// 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.Globalization;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// An input component for editing numeric values.
+    /// Supported numeric types are <see cref="int"/>, <see cref="long"/>, <see cref="float"/>, <see cref="double"/>, <see cref="decimal"/>.
+    /// </summary>
+    public class InputNumber<T> : InputBase<T>
+    {
+        delegate bool Parser(string value, out T result);
+        private static Parser _parser;
+        private static string _stepAttributeValue; // Null by default, so only allows whole numbers as per HTML spec
+
+        // Determine the parsing logic once per T and cache it, so we don't have to consider all the possible types on each parse
+        static InputNumber()
+        {
+            // Unwrap Nullable<T>, because InputBase already deals with the Nullable aspect
+            // of it for us. We will only get asked to parse the T for nonempty inputs.
+            var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
+
+            if (targetType == typeof(int))
+            {
+                _parser = TryParseInt;
+            }
+            else if (targetType == typeof(long))
+            {
+                _parser = TryParseLong;
+            }
+            else if (targetType == typeof(float))
+            {
+                _parser = TryParseFloat;
+                _stepAttributeValue = "any";
+            }
+            else if (targetType == typeof(double))
+            {
+                _parser = TryParseDouble;
+                _stepAttributeValue = "any";
+            }
+            else if (targetType == typeof(decimal))
+            {
+                _parser = TryParseDecimal;
+                _stepAttributeValue = "any";
+            }
+            else
+            {
+                throw new InvalidOperationException($"The type '{targetType}' is not a supported numeric type.");
+            }
+        }
+
+        [Parameter] string ParsingErrorMessage { get; set; } = "The {0} field must be a number.";
+
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+            builder.OpenElement(0, "input");
+            builder.AddAttribute(1, "type", "number");
+            builder.AddAttribute(2, "step", _stepAttributeValue);
+            builder.AddAttribute(3, "id", Id);
+            builder.AddAttribute(4, "class", CssClass);
+            builder.AddAttribute(5, "value", BindMethods.GetValue(CurrentValueAsString));
+            builder.AddAttribute(6, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString));
+            builder.CloseElement();
+        }
+
+        /// <inheritdoc />
+        protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage)
+        {
+            if (_parser(value, out result))
+            {
+                validationErrorMessage = null;
+                return true;
+            }
+            else
+            {
+                validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName);
+                return false;
+            }
+        }
+
+        static bool TryParseInt(string value, out T result)
+        {
+            var success = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+
+        static bool TryParseLong(string value, out T result)
+        {
+            var success = long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+
+        static bool TryParseFloat(string value, out T result)
+        {
+            var success = float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+
+        static bool TryParseDouble(string value, out T result)
+        {
+            var success = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+
+        static bool TryParseDecimal(string value, out T result)
+        {
+            var success = decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue);
+            if (success)
+            {
+                result = (T)(object)parsedValue;
+                return true;
+            }
+            else
+            {
+                result = default;
+                return false;
+            }
+        }
+    }
+}

+ 58 - 0
src/Components/Components/src/Forms/InputComponents/InputSelect.cs

@@ -0,0 +1,58 @@
+// 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 Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// A dropdown selection component.
+    /// </summary>
+    public class InputSelect<T> : InputBase<T>
+    {
+        [Parameter] RenderFragment ChildContent { get; set; }
+
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+            builder.OpenElement(0, "select");
+            builder.AddAttribute(1, "id", Id);
+            builder.AddAttribute(2, "class", CssClass);
+            builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValueAsString));
+            builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValueAsString = __value, CurrentValueAsString));
+            builder.AddContent(5, ChildContent);
+            builder.CloseElement();
+        }
+
+        /// <inheritdoc />
+        protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage)
+        {
+            if (typeof(T) == typeof(string))
+            {
+                result = (T)(object)value;
+                validationErrorMessage = null;
+                return true;
+            }
+            else if (typeof(T).IsEnum)
+            {
+                // There's no non-generic Enum.TryParse (https://github.com/dotnet/corefx/issues/692)
+                try
+                {
+                    result = (T)Enum.Parse(typeof(T), value);
+                    validationErrorMessage = null;
+                    return true;
+                }
+                catch (ArgumentException)
+                {
+                    result = default;
+                    validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid.";
+                    return false;
+                }
+            }
+
+            throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(T)}'.");
+        }
+    }
+}

+ 44 - 0
src/Components/Components/src/Forms/InputComponents/InputText.cs

@@ -0,0 +1,44 @@
+// 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 Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    // TODO: Support maxlength etc.
+
+    /* This is almost equivalent to a .razor file containing:
+     *
+     *    @inherits InputBase<string>
+     *    <input bind="@CurrentValue" id="@Id" class="@CssClass" />
+     *
+     * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those
+     * files within this project. Developers building their own input components should use Razor syntax.
+     */
+
+    /// <summary>
+    /// An input component for editing <see cref="string"/> values.
+    /// </summary>
+    public class InputText : InputBase<string>
+    {
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+            builder.OpenElement(0, "input");
+            builder.AddAttribute(1, "id", Id);
+            builder.AddAttribute(2, "class", CssClass);
+            builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue));
+            builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue));
+            builder.CloseElement();
+        }
+
+        /// <inheritdoc />
+        protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage)
+        {
+            result = value;
+            validationErrorMessage = null;
+            return true;
+        }
+    }
+}

+ 44 - 0
src/Components/Components/src/Forms/InputComponents/InputTextArea.cs

@@ -0,0 +1,44 @@
+// 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 Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    // TODO: Support rows/cols/etc
+
+    /* This is almost equivalent to a .razor file containing:
+     *
+     *    @inherits InputBase<string>
+     *    <textarea bind="@CurrentValue" id="@Id" class="@CssClass"></textarea>
+     *
+     * The only reason it's not implemented as a .razor file is that we don't presently have the ability to compile those
+     * files within this project. Developers building their own input components should use Razor syntax.
+     */
+
+    /// <summary>
+    /// A multiline input component for editing <see cref="string"/> values.
+    /// </summary>
+    public class InputTextArea : InputBase<string>
+    {
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+            builder.OpenElement(0, "textarea");
+            builder.AddAttribute(1, "id", Id);
+            builder.AddAttribute(2, "class", CssClass);
+            builder.AddAttribute(3, "value", BindMethods.GetValue(CurrentValue));
+            builder.AddAttribute(4, "onchange", BindMethods.SetValueHandler(__value => CurrentValue = __value, CurrentValue));
+            builder.CloseElement();
+        }
+
+        /// <inheritdoc />
+        protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage)
+        {
+            result = value;
+            validationErrorMessage = null;
+            return true;
+        }
+    }
+}

+ 96 - 0
src/Components/Components/src/Forms/ValidationMessage.cs

@@ -0,0 +1,96 @@
+// 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.Linq.Expressions;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Displays a list of validation messages for a specified field within a cascaded <see cref="EditContext"/>.
+    /// </summary>
+    public class ValidationMessage<T> : ComponentBase, IDisposable
+    {
+        private EditContext _previousEditContext;
+        private Expression<Func<T>> _previousFieldAccessor;
+        private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
+        private FieldIdentifier _fieldIdentifier;
+
+        [CascadingParameter] EditContext CurrentEditContext { get; set; }
+
+        /// <summary>
+        /// Specifies the field for which validation messages should be displayed.
+        /// </summary>
+        [Parameter] Expression<Func<T>> For { get; set; }
+
+        /// <summary>`
+        /// Constructs an instance of <see cref="ValidationSummary"/>.
+        /// </summary>
+        public ValidationMessage()
+        {
+            _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
+        }
+
+        /// <inheritdoc />
+        protected override void OnParametersSet()
+        {
+            if (CurrentEditContext == null)
+            {
+                throw new InvalidOperationException($"{GetType()} requires a cascading parameter " +
+                    $"of type {nameof(EditContext)}. For example, you can use {GetType()} inside " +
+                    $"an {nameof(EditForm)}.");
+            }
+
+            if (For == null) // Not possible except if you manually specify T
+            {
+                throw new InvalidOperationException($"{GetType()} requires a value for the " +
+                    $"{nameof(For)} parameter.");
+            }
+            else if (For != _previousFieldAccessor)
+            {
+                _fieldIdentifier = FieldIdentifier.Create(For);
+                _previousFieldAccessor = For;
+            }
+
+            if (CurrentEditContext != _previousEditContext)
+            {
+                DetachValidationStateChangedListener();
+                CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler;
+                _previousEditContext = CurrentEditContext;
+            }
+        }
+
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+
+            foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier))
+            {
+                builder.OpenElement(0, "div");
+                builder.AddAttribute(1, "class", "validation-message");
+                builder.AddContent(2, message);
+                builder.CloseElement();
+            }
+        }
+
+        private void HandleValidationStateChanged(object sender, ValidationStateChangedEventArgs eventArgs)
+        {
+            StateHasChanged();
+        }
+
+        void IDisposable.Dispose()
+        {
+            DetachValidationStateChangedListener();
+        }
+
+        private void DetachValidationStateChangedListener()
+        {
+            if (_previousEditContext != null)
+            {
+                _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler;
+            }
+        }
+    }
+}

+ 105 - 0
src/Components/Components/src/Forms/ValidationMessageStore.cs

@@ -0,0 +1,105 @@
+// 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.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Holds validation messages for an <see cref="EditContext"/>.
+    /// </summary>
+    public sealed class ValidationMessageStore
+    {
+        private readonly EditContext _editContext;
+        private readonly Dictionary<FieldIdentifier, List<string>> _messages = new Dictionary<FieldIdentifier, List<string>>();
+
+        /// <summary>
+        /// Creates an instance of <see cref="ValidationMessageStore"/>.
+        /// </summary>
+        /// <param name="editContext">The <see cref="EditContext"/> with which this store should be associated.</param>
+        public ValidationMessageStore(EditContext editContext)
+        {
+            _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
+        }
+
+        /// <summary>
+        /// Adds a validation message for the specified field.
+        /// </summary>
+        /// <param name="fieldIdentifier">The identifier for the field.</param>
+        /// <param name="message">The validation message.</param>
+        public void Add(in FieldIdentifier fieldIdentifier, string message)
+            => GetOrCreateMessagesListForField(fieldIdentifier).Add(message);
+
+        /// <summary>
+        /// Adds the messages from the specified collection for the specified field.
+        /// </summary>
+        /// <param name="fieldIdentifier">The identifier for the field.</param>
+        /// <param name="messages">The validation messages to be added.</param>
+        public void AddRange(in FieldIdentifier fieldIdentifier, IEnumerable<string> messages)
+            => GetOrCreateMessagesListForField(fieldIdentifier).AddRange(messages);
+
+        /// <summary>
+        /// Gets the validation messages within this <see cref="ValidationMessageStore"/> for the specified field.
+        ///
+        /// To get the validation messages across all validation message stores, use <see cref="EditContext.GetValidationMessages(FieldIdentifier)"/> instead
+        /// </summary>
+        /// <param name="fieldIdentifier">The identifier for the field.</param>
+        /// <returns>The validation messages for the specified field within this <see cref="ValidationMessageStore"/>.</returns>
+        public IEnumerable<string> this[FieldIdentifier fieldIdentifier]
+            => _messages.TryGetValue(fieldIdentifier, out var messages) ? messages : Enumerable.Empty<string>();
+
+        /// <summary>
+        /// Gets the validation messages within this <see cref="ValidationMessageStore"/> for the specified field.
+        ///
+        /// To get the validation messages across all validation message stores, use <see cref="EditContext.GetValidationMessages(FieldIdentifier)"/> instead
+        /// </summary>
+        /// <param name="accessor">The identifier for the field.</param>
+        /// <returns>The validation messages for the specified field within this <see cref="ValidationMessageStore"/>.</returns>
+        public IEnumerable<string> this[Expression<Func<object>> accessor]
+            => this[FieldIdentifier.Create(accessor)];
+
+        /// <summary>
+        /// Removes all messages within this <see cref="ValidationMessageStore"/>.
+        /// </summary>
+        public void Clear()
+        {
+            foreach (var fieldIdentifier in _messages.Keys)
+            {
+                DissociateFromField(fieldIdentifier);
+            }
+
+            _messages.Clear();
+        }
+
+        /// <summary>
+        /// Removes all messages within this <see cref="ValidationMessageStore"/> for the specified field.
+        /// </summary>
+        /// <param name="fieldIdentifier">The identifier for the field.</param>
+        public void Clear(in FieldIdentifier fieldIdentifier)
+        {
+            DissociateFromField(fieldIdentifier);
+            _messages.Remove(fieldIdentifier);
+        }
+
+        private List<string> GetOrCreateMessagesListForField(in FieldIdentifier fieldIdentifier)
+        {
+            if (!_messages.TryGetValue(fieldIdentifier, out var messagesForField))
+            {
+                messagesForField = new List<string>();
+                _messages.Add(fieldIdentifier, messagesForField);
+                AssociateWithField(fieldIdentifier);
+            }
+
+            return messagesForField;
+        }
+
+        private void AssociateWithField(in FieldIdentifier fieldIdentifier)
+            => _editContext.GetFieldState(fieldIdentifier, ensureExists: true).AssociateWithValidationMessageStore(this);
+
+        private void DissociateFromField(in FieldIdentifier fieldIdentifier)
+            => _editContext.GetFieldState(fieldIdentifier, ensureExists: false)?.DissociateFromValidationMessageStore(this);
+    }
+}

+ 41 - 0
src/Components/Components/src/Forms/ValidationMessageStoreExpressionExtensions.cs

@@ -0,0 +1,41 @@
+// 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.Collections.Generic;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Provides extension methods to simplify using <see cref="ValidationMessageStore"/> with expressions.
+    /// </summary>
+    public static class ValidationMessageStoreExpressionExtensions
+    {
+        /// <summary>
+        /// Adds a validation message for the specified field.
+        /// </summary>
+        /// <param name="store">The <see cref="ValidationMessageStore"/>.</param>
+        /// <param name="accessor">Identifies the field for which to add the message.</param>
+        /// <param name="message">The validation message.</param>
+        public static void Add(this ValidationMessageStore store, Expression<Func<object>> accessor, string message)
+            => store.Add(FieldIdentifier.Create(accessor), message);
+
+        /// <summary>
+        /// Adds the messages from the specified collection for the specified field.
+        /// </summary>
+        /// <param name="store">The <see cref="ValidationMessageStore"/>.</param>
+        /// <param name="accessor">Identifies the field for which to add the messages.</param>
+        /// <param name="messages">The validation messages to be added.</param>
+        public static void AddRange(this ValidationMessageStore store, Expression<Func<object>> accessor, IEnumerable<string> messages)
+            => store.AddRange(FieldIdentifier.Create(accessor), messages);
+
+        /// <summary>
+        /// Removes all messages within this <see cref="ValidationMessageStore"/> for the specified field.
+        /// </summary>
+        /// <param name="store">The <see cref="ValidationMessageStore"/>.</param>
+        /// <param name="accessor">Identifies the field for which to remove the messages.</param>
+        public static void Clear(this ValidationMessageStore store, Expression<Func<object>> accessor)
+            => store.Clear(FieldIdentifier.Create(accessor));
+    }
+}

+ 17 - 0
src/Components/Components/src/Forms/ValidationRequestedEventArgs.cs

@@ -0,0 +1,17 @@
+// 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.
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Provides information about the <see cref="EditContext.OnValidationRequested"/> event.
+    /// </summary>
+    public sealed class ValidationRequestedEventArgs
+    {
+        internal static readonly ValidationRequestedEventArgs Empty = new ValidationRequestedEventArgs();
+
+        internal ValidationRequestedEventArgs()
+        {
+        }
+    }
+}

+ 17 - 0
src/Components/Components/src/Forms/ValidationStateChangedEventArgs.cs

@@ -0,0 +1,17 @@
+// 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.
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    /// <summary>
+    /// Provides information about the <see cref="EditContext.OnValidationStateChanged"/> event.
+    /// </summary>
+    public sealed class ValidationStateChangedEventArgs
+    {
+        internal static readonly ValidationStateChangedEventArgs Empty = new ValidationStateChangedEventArgs();
+
+        internal ValidationStateChangedEventArgs()
+        {
+        }
+    }
+}

+ 93 - 0
src/Components/Components/src/Forms/ValidationSummary.cs

@@ -0,0 +1,93 @@
+// 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 Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    // Note: there's no reason why developers strictly need to use this. It's equally valid to
+    // put a @foreach(var message in context.GetValidationMessages()) { ... } inside a form.
+    // This component is for convenience only, plus it implements a few small perf optimizations.
+
+    /// <summary>
+    /// Displays a list of validation messages from a cascaded <see cref="EditContext"/>.
+    /// </summary>
+    public class ValidationSummary : ComponentBase, IDisposable
+    {
+        private EditContext _previousEditContext;
+        private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
+
+        [CascadingParameter] EditContext CurrentEditContext { get; set; }
+
+        /// <summary>`
+        /// Constructs an instance of <see cref="ValidationSummary"/>.
+        /// </summary>
+        public ValidationSummary()
+        {
+            _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
+        }
+
+        /// <inheritdoc />
+        protected override void OnParametersSet()
+        {
+            if (CurrentEditContext == null)
+            {
+                throw new InvalidOperationException($"{nameof(ValidationSummary)} requires a cascading parameter " +
+                    $"of type {nameof(EditContext)}. For example, you can use {nameof(ValidationSummary)} inside " +
+                    $"an {nameof(EditForm)}.");
+            }
+
+            if (CurrentEditContext != _previousEditContext)
+            {
+                DetachValidationStateChangedListener();
+                CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler;
+                _previousEditContext = CurrentEditContext;
+            }
+        }
+
+        /// <inheritdoc />
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            base.BuildRenderTree(builder);
+
+            // As an optimization, only evaluate the messages enumerable once, and
+            // only produce the enclosing <ul> if there's at least one message
+            var messagesEnumerator = CurrentEditContext.GetValidationMessages().GetEnumerator();
+            if (messagesEnumerator.MoveNext())
+            {
+                builder.OpenElement(0, "ul");
+                builder.AddAttribute(1, "class", "validation-errors");
+
+                do
+                {
+                    builder.OpenElement(2, "li");
+                    builder.AddAttribute(3, "class", "validation-message");
+                    builder.AddContent(4, messagesEnumerator.Current);
+                    builder.CloseElement();
+                }
+                while (messagesEnumerator.MoveNext());
+
+                builder.CloseElement();
+            }
+        }
+
+        private void HandleValidationStateChanged(object sender, ValidationStateChangedEventArgs eventArgs)
+        {
+            StateHasChanged();
+        }
+
+        void IDisposable.Dispose()
+        {
+            DetachValidationStateChangedListener();
+        }
+
+        private void DetachValidationStateChangedListener()
+        {
+            if (_previousEditContext != null)
+            {
+                _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler;
+            }
+        }
+    }
+}

+ 1 - 0
src/Components/Components/src/Microsoft.AspNetCore.Components.csproj

@@ -10,6 +10,7 @@
 
   <ItemGroup>
     <Reference Include="Microsoft.JSInterop" />
+    <Reference Include="System.ComponentModel.Annotations" />
   </ItemGroup>
 
 </Project>

+ 170 - 0
src/Components/Components/test/Forms/EditContextDataAnnotationsExtensionsTest.cs

@@ -0,0 +1,170 @@
+// 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.ComponentModel.DataAnnotations;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    public class EditContextDataAnnotationsExtensionsTest
+    {
+        [Fact]
+        public void CannotUseNullEditContext()
+        {
+            var editContext = (EditContext)null;
+            var ex = Assert.Throws<ArgumentNullException>(() => editContext.AddDataAnnotationsValidation());
+            Assert.Equal("editContext", ex.ParamName);
+        }
+
+        [Fact]
+        public void ReturnsEditContextForChaining()
+        {
+            var editContext = new EditContext(new object());
+            var returnValue = editContext.AddDataAnnotationsValidation();
+            Assert.Same(editContext, returnValue);
+        }
+
+        [Fact]
+        public void GetsValidationMessagesFromDataAnnotations()
+        {
+            // Arrange
+            var model = new TestModel { IntFrom1To100 = 101 };
+            var editContext = new EditContext(model).AddDataAnnotationsValidation();
+
+            // Act
+            var isValid = editContext.Validate();
+
+            // Assert
+            Assert.False(isValid);
+
+            Assert.Equal(new string[]
+                {
+                    "RequiredString:required",
+                    "IntFrom1To100:range"
+                },
+                editContext.GetValidationMessages());
+
+            Assert.Equal(new string[] { "RequiredString:required" },
+                editContext.GetValidationMessages(editContext.Field(nameof(TestModel.RequiredString))));
+
+            // This shows we're including non-[Required] properties in the validation results, i.e,
+            // that we're correctly passing "validateAllProperties: true" to DataAnnotations
+            Assert.Equal(new string[] { "IntFrom1To100:range" },
+                editContext.GetValidationMessages(editContext.Field(nameof(TestModel.IntFrom1To100))));
+        }
+
+        [Fact]
+        public void ClearsExistingValidationMessagesOnFurtherRuns()
+        {
+            // Arrange
+            var model = new TestModel { IntFrom1To100 = 101 };
+            var editContext = new EditContext(model).AddDataAnnotationsValidation();
+
+            // Act/Assert 1: Initially invalid
+            Assert.False(editContext.Validate());
+
+            // Act/Assert 2: Can become valid
+            model.RequiredString = "Hello";
+            model.IntFrom1To100 = 100;
+            Assert.True(editContext.Validate());
+        }
+
+        [Fact]
+        public void NotifiesValidationStateChangedAfterObjectValidation()
+        {
+            // Arrange
+            var model = new TestModel { IntFrom1To100 = 101 };
+            var editContext = new EditContext(model).AddDataAnnotationsValidation();
+            var onValidationStateChangedCount = 0;
+            editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
+
+            // Act/Assert 1: Notifies after invalid results
+            Assert.False(editContext.Validate());
+            Assert.Equal(1, onValidationStateChangedCount);
+
+            // Act/Assert 2: Notifies after valid results
+            model.RequiredString = "Hello";
+            model.IntFrom1To100 = 100;
+            Assert.True(editContext.Validate());
+            Assert.Equal(2, onValidationStateChangedCount);
+
+            // Act/Assert 3: Notifies even if results haven't changed. Later we might change the
+            // logic to track the previous results and compare with the new ones, but that's just
+            // an optimization. It's legal to notify regardless.
+            Assert.True(editContext.Validate());
+            Assert.Equal(3, onValidationStateChangedCount);
+        }
+
+        [Fact]
+        public void PerformsPerPropertyValidationOnFieldChange()
+        {
+            // Arrange
+            var model = new TestModel { IntFrom1To100 = 101 };
+            var independentTopLevelModel = new object(); // To show we can validate things on any model, not just the top-level one
+            var editContext = new EditContext(independentTopLevelModel).AddDataAnnotationsValidation();
+            var onValidationStateChangedCount = 0;
+            var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString));
+            var intFrom1To100Identifier = new FieldIdentifier(model, nameof(TestModel.IntFrom1To100));
+            editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
+
+            // Act/Assert 1: Notify about RequiredString
+            // Only RequiredString gets validated, even though IntFrom1To100 also holds an invalid value
+            editContext.NotifyFieldChanged(requiredStringIdentifier);
+            Assert.Equal(1, onValidationStateChangedCount);
+            Assert.Equal(new[] { "RequiredString:required" }, editContext.GetValidationMessages());
+
+            // Act/Assert 2: Fix RequiredString, but only notify about IntFrom1To100
+            // Only IntFrom1To100 gets validated; messages for RequiredString are left unchanged
+            model.RequiredString = "This string is very cool and very legal";
+            editContext.NotifyFieldChanged(intFrom1To100Identifier);
+            Assert.Equal(2, onValidationStateChangedCount);
+            Assert.Equal(new string[]
+                {
+                    "RequiredString:required",
+                    "IntFrom1To100:range"
+                },
+                editContext.GetValidationMessages());
+
+            // Act/Assert 3: Notify about RequiredString
+            editContext.NotifyFieldChanged(requiredStringIdentifier);
+            Assert.Equal(3, onValidationStateChangedCount);
+            Assert.Equal(new[] { "IntFrom1To100:range" }, editContext.GetValidationMessages());
+        }
+
+        [Theory]
+        [InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsAField))]
+        [InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsInternal))]
+        [InlineData("ThisWillNotBeValidatedBecauseItIsPrivate")]
+        [InlineData("This does not correspond to anything")]
+        [InlineData("")]
+        public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string fieldName)
+        {
+            // Arrange
+            var editContext = new EditContext(new TestModel()).AddDataAnnotationsValidation();
+            var onValidationStateChangedCount = 0;
+            editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
+
+            // Act/Assert: Ignores field changes that don't correspond to a validatable property
+            editContext.NotifyFieldChanged(editContext.Field(fieldName));
+            Assert.Equal(0, onValidationStateChangedCount);
+
+            // Act/Assert: For sanity, observe that we would have validated if it was a validatable property
+            editContext.NotifyFieldChanged(editContext.Field(nameof(TestModel.RequiredString)));
+            Assert.Equal(1, onValidationStateChangedCount);
+        }
+
+        class TestModel
+        {
+            [Required(ErrorMessage = "RequiredString:required")] public string RequiredString { get; set; }
+
+            [Range(1, 100, ErrorMessage = "IntFrom1To100:range")] public int IntFrom1To100 { get; set; }
+
+#pragma warning disable 649
+            [Required] public string ThisWillNotBeValidatedBecauseItIsAField;
+            [Required] string ThisWillNotBeValidatedBecauseItIsPrivate { get; set; }
+            [Required] internal string ThisWillNotBeValidatedBecauseItIsInternal { get; set; }
+#pragma warning restore 649
+        }
+    }
+}

+ 240 - 0
src/Components/Components/test/Forms/EditContextTest.cs

@@ -0,0 +1,240 @@
+// 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.Linq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    public class EditContextTest
+    {
+        [Fact]
+        public void CannotUseNullModel()
+        {
+            var ex = Assert.Throws<ArgumentNullException>(() => new EditContext(null));
+            Assert.Equal("model", ex.ParamName);
+        }
+
+        [Fact]
+        public void CanGetModel()
+        {
+            var model = new object();
+            var editContext = new EditContext(model);
+            Assert.Same(model, editContext.Model);
+        }
+
+        [Fact]
+        public void CanConstructFieldIdentifiersForRootModel()
+        {
+            // Arrange/Act
+            var model = new object();
+            var editContext = new EditContext(model);
+            var fieldIdentifier = editContext.Field("testFieldName");
+
+            // Assert
+            Assert.Same(model, fieldIdentifier.Model);
+            Assert.Equal("testFieldName", fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void IsInitiallyUnmodified()
+        {
+            var editContext = new EditContext(new object());
+            Assert.False(editContext.IsModified());
+        }
+
+        [Fact]
+        public void TracksFieldsAsModifiedWhenValueChanged()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var fieldOnThisModel1 = editContext.Field("field1");
+            var fieldOnThisModel2 = editContext.Field("field2");
+            var fieldOnOtherModel = new FieldIdentifier(new object(), "field on other model");
+
+            // Act
+            editContext.NotifyFieldChanged(fieldOnThisModel1);
+            editContext.NotifyFieldChanged(fieldOnOtherModel);
+
+            // Assert
+            Assert.True(editContext.IsModified());
+            Assert.True(editContext.IsModified(fieldOnThisModel1));
+            Assert.False(editContext.IsModified(fieldOnThisModel2));
+            Assert.True(editContext.IsModified(fieldOnOtherModel));
+        }
+        
+        [Fact]
+        public void CanClearIndividualModifications()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var fieldThatWasModified = editContext.Field("field1");
+            var fieldThatRemainsModified = editContext.Field("field2");
+            var fieldThatWasNeverModified = editContext.Field("field that was never modified");
+            editContext.NotifyFieldChanged(fieldThatWasModified);
+            editContext.NotifyFieldChanged(fieldThatRemainsModified);
+
+            // Act
+            editContext.MarkAsUnmodified(fieldThatWasModified);
+            editContext.MarkAsUnmodified(fieldThatWasNeverModified);
+
+            // Assert
+            Assert.True(editContext.IsModified());
+            Assert.False(editContext.IsModified(fieldThatWasModified));
+            Assert.True(editContext.IsModified(fieldThatRemainsModified));
+            Assert.False(editContext.IsModified(fieldThatWasNeverModified));
+        }
+
+        [Fact]
+        public void CanClearAllModifications()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var field1 = editContext.Field("field1");
+            var field2 = editContext.Field("field2");
+            editContext.NotifyFieldChanged(field1);
+            editContext.NotifyFieldChanged(field2);
+
+            // Act
+            editContext.MarkAsUnmodified();
+
+            // Assert
+            Assert.False(editContext.IsModified());
+            Assert.False(editContext.IsModified(field1));
+            Assert.False(editContext.IsModified(field2));
+        }
+
+        [Fact]
+        public void RaisesEventWhenFieldIsChanged()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var field1 = new FieldIdentifier(new object(), "fieldname"); // Shows it can be on a different model
+            var didReceiveNotification = false;
+            editContext.OnFieldChanged += (sender, eventArgs) =>
+            {
+                Assert.Same(editContext, sender);
+                Assert.Equal(field1, eventArgs.FieldIdentifier);
+                didReceiveNotification = true;
+            };
+
+            // Act
+            editContext.NotifyFieldChanged(field1);
+
+            // Assert
+            Assert.True(didReceiveNotification);
+        }
+
+        [Fact]
+        public void CanEnumerateValidationMessagesAcrossAllStoresForSingleField()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var store1 = new ValidationMessageStore(editContext);
+            var store2 = new ValidationMessageStore(editContext);
+            var field = new FieldIdentifier(new object(), "field");
+            var fieldWithNoState = new FieldIdentifier(new object(), "field with no state");
+            store1.Add(field, "Store 1 message 1");
+            store1.Add(field, "Store 1 message 2");
+            store1.Add(new FieldIdentifier(new object(), "otherfield"), "Message for other field that should not appear in results");
+            store2.Add(field, "Store 2 message 1");
+
+            // Act/Assert: Can pick out the messages for a field
+            Assert.Equal(new[]
+            {
+                "Store 1 message 1",
+                "Store 1 message 2",
+                "Store 2 message 1",
+            }, editContext.GetValidationMessages(field).OrderBy(x => x)); // Sort because the order isn't defined
+
+            // Act/Assert: It's fine to ask for messages for a field with no associated state
+            Assert.Empty(editContext.GetValidationMessages(fieldWithNoState));
+
+            // Act/Assert: After clearing a single store, we only see the results from other stores
+            store1.Clear(field);
+            Assert.Equal(new[] { "Store 2 message 1", }, editContext.GetValidationMessages(field));
+        }
+
+        [Fact]
+        public void CanEnumerateValidationMessagesAcrossAllStoresForAllFields()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var store1 = new ValidationMessageStore(editContext);
+            var store2 = new ValidationMessageStore(editContext);
+            var field1 = new FieldIdentifier(new object(), "field1");
+            var field2 = new FieldIdentifier(new object(), "field2");
+            store1.Add(field1, "Store 1 field 1 message 1");
+            store1.Add(field1, "Store 1 field 1 message 2");
+            store1.Add(field2, "Store 1 field 2 message 1");
+            store2.Add(field1, "Store 2 field 1 message 1");
+
+            // Act/Assert
+            Assert.Equal(new[]
+            {
+                "Store 1 field 1 message 1",
+                "Store 1 field 1 message 2",
+                "Store 1 field 2 message 1",
+                "Store 2 field 1 message 1",
+            }, editContext.GetValidationMessages().OrderBy(x => x)); // Sort because the order isn't defined
+
+            // Act/Assert: After clearing a single store, we only see the results from other stores
+            store1.Clear();
+            Assert.Equal(new[] { "Store 2 field 1 message 1", }, editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void IsValidWithNoValidationMessages()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+
+            // Act
+            var isValid = editContext.Validate();
+
+            // assert
+            Assert.True(isValid);
+        }
+
+        [Fact]
+        public void IsInvalidWithValidationMessages()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var messages = new ValidationMessageStore(editContext);
+            messages.Add(
+                new FieldIdentifier(new object(), "some field"),
+                "Some message");
+
+            // Act
+            var isValid = editContext.Validate();
+
+            // assert
+            Assert.False(isValid);
+        }
+
+        [Fact]
+        public void RequestsValidationWhenValidateIsCalled()
+        {
+            // Arrange
+            var editContext = new EditContext(new object());
+            var messages = new ValidationMessageStore(editContext);
+            editContext.OnValidationRequested += (sender, eventArgs) =>
+            {
+                Assert.Same(editContext, sender);
+                Assert.NotNull(eventArgs);
+                messages.Add(
+                    new FieldIdentifier(new object(), "some field"),
+                    "Some message");
+            };
+
+            // Act
+            var isValid = editContext.Validate();
+
+            // assert
+            Assert.False(isValid);
+            Assert.Equal(new[] { "Some message" }, editContext.GetValidationMessages());
+        }
+    }
+}

+ 198 - 0
src/Components/Components/test/Forms/FieldIdentifierTest.cs

@@ -0,0 +1,198 @@
+// 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.Collections.Generic;
+using System.Linq.Expressions;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    public class FieldIdentifierTest
+    {
+        [Fact]
+        public void CannotUseNullModel()
+        {
+            var ex = Assert.Throws<ArgumentNullException>(() => new FieldIdentifier(null, "somefield"));
+            Assert.Equal("model", ex.ParamName);
+        }
+
+        [Fact]
+        public void CannotUseValueTypeModel()
+        {
+            var ex = Assert.Throws<ArgumentException>(() => new FieldIdentifier(DateTime.Now, "somefield"));
+            Assert.Equal("model", ex.ParamName);
+            Assert.StartsWith("The model must be a reference-typed object.", ex.Message);
+        }
+
+        [Fact]
+        public void CannotUseNullFieldName()
+        {
+            var ex = Assert.Throws<ArgumentNullException>(() => new FieldIdentifier(new object(), null));
+            Assert.Equal("fieldName", ex.ParamName);
+        }
+
+        [Fact]
+        public void CanUseEmptyFieldName()
+        {
+            var fieldIdentifier = new FieldIdentifier(new object(), string.Empty);
+            Assert.Equal(string.Empty, fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CanGetModelAndFieldName()
+        {
+            // Arrange/Act
+            var model = new object();
+            var fieldIdentifier = new FieldIdentifier(model, "someField");
+
+            // Assert
+            Assert.Same(model, fieldIdentifier.Model);
+            Assert.Equal("someField", fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void DistinctModelsProduceDistinctHashCodesAndNonEquality()
+        {
+            // Arrange
+            var fieldIdentifier1 = new FieldIdentifier(new object(), "field");
+            var fieldIdentifier2 = new FieldIdentifier(new object(), "field");
+
+            // Act/Assert
+            Assert.NotEqual(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode());
+            Assert.False(fieldIdentifier1.Equals(fieldIdentifier2));
+        }
+
+        [Fact]
+        public void DistinctFieldNamesProduceDistinctHashCodesAndNonEquality()
+        {
+            // Arrange
+            var model = new object();
+            var fieldIdentifier1 = new FieldIdentifier(model, "field1");
+            var fieldIdentifier2 = new FieldIdentifier(model, "field2");
+
+            // Act/Assert
+            Assert.NotEqual(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode());
+            Assert.False(fieldIdentifier1.Equals(fieldIdentifier2));
+        }
+
+        [Fact]
+        public void SameContentsProduceSameHashCodesAndEquality()
+        {
+            // Arrange
+            var model = new object();
+            var fieldIdentifier1 = new FieldIdentifier(model, "field");
+            var fieldIdentifier2 = new FieldIdentifier(model, "field");
+
+            // Act/Assert
+            Assert.Equal(fieldIdentifier1.GetHashCode(), fieldIdentifier2.GetHashCode());
+            Assert.True(fieldIdentifier1.Equals(fieldIdentifier2));
+        }
+
+        [Fact]
+        public void FieldNamesAreCaseSensitive()
+        {
+            // Arrange
+            var model = new object();
+            var fieldIdentifierLower = new FieldIdentifier(model, "field");
+            var fieldIdentifierPascal = new FieldIdentifier(model, "Field");
+
+            // Act/Assert
+            Assert.Equal("field", fieldIdentifierLower.FieldName);
+            Assert.Equal("Field", fieldIdentifierPascal.FieldName);
+            Assert.NotEqual(fieldIdentifierLower.GetHashCode(), fieldIdentifierPascal.GetHashCode());
+            Assert.False(fieldIdentifierLower.Equals(fieldIdentifierPascal));
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_Property()
+        {
+            var model = new TestModel();
+            var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
+            Assert.Same(model, fieldIdentifier.Model);
+            Assert.Equal(nameof(model.StringProperty), fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CannotCreateFromExpression_NonMember()
+        {
+            var ex = Assert.Throws<ArgumentException>(() =>
+                FieldIdentifier.Create(() => new TestModel()));
+            Assert.Equal($"The provided expression contains a NewExpression which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.", ex.Message);
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_Field()
+        {
+            var model = new TestModel();
+            var fieldIdentifier = FieldIdentifier.Create(() => model.StringField);
+            Assert.Same(model, fieldIdentifier.Model);
+            Assert.Equal(nameof(model.StringField), fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_WithCastToObject()
+        {
+            // This case is needed because, if a component is declared as receiving
+            // an Expression<Func<object>>, then any value types will be implicitly cast
+            var model = new TestModel();
+            Expression<Func<object>> accessor = () => model.IntProperty;
+            var fieldIdentifier = FieldIdentifier.Create(accessor);
+            Assert.Same(model, fieldIdentifier.Model);
+            Assert.Equal(nameof(model.IntProperty), fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_MemberOfConstantExpression()
+        {
+            var fieldIdentifier = FieldIdentifier.Create(() => StringPropertyOnThisClass);
+            Assert.Same(this, fieldIdentifier.Model);
+            Assert.Equal(nameof(StringPropertyOnThisClass), fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_MemberOfChildObject()
+        {
+            var parentModel = new ParentModel { Child = new TestModel() };
+            var fieldIdentifier = FieldIdentifier.Create(() => parentModel.Child.StringField);
+            Assert.Same(parentModel.Child, fieldIdentifier.Model);
+            Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_MemberOfIndexedCollectionEntry()
+        {
+            var models = new List<TestModel>() { null, new TestModel() };
+            var fieldIdentifier = FieldIdentifier.Create(() => models[1].StringField);
+            Assert.Same(models[1], fieldIdentifier.Model);
+            Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName);
+        }
+
+        [Fact]
+        public void CanCreateFromExpression_MemberOfObjectWithCast()
+        {
+            var model = new TestModel();
+            var fieldIdentifier = FieldIdentifier.Create(() => ((TestModel)(object)model).StringField);
+            Assert.Same(model, fieldIdentifier.Model);
+            Assert.Equal(nameof(TestModel.StringField), fieldIdentifier.FieldName);
+        }
+
+        string StringPropertyOnThisClass { get; set; }
+
+        class TestModel
+        {
+            public string StringProperty { get; set; }
+
+            public int IntProperty { get; set; }
+
+#pragma warning disable 649
+            public string StringField;
+#pragma warning restore 649
+        }
+
+        class ParentModel
+        {
+            public TestModel Child { get; set; }
+        }
+    }
+}

+ 472 - 0
src/Components/Components/test/Forms/InputBaseTest.cs

@@ -0,0 +1,472 @@
+// 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.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    public class InputBaseTest
+    {
+        [Fact]
+        public async Task ThrowsOnFirstRenderIfNoEditContextIsSupplied()
+        {
+            // Arrange
+            var inputComponent = new TestInputComponent<string>();
+            var testRenderer = new TestRenderer();
+            var componentId = testRenderer.AssignRootComponentId(inputComponent);
+            
+            // Act/Assert
+            var ex = await Assert.ThrowsAsync<InvalidOperationException>(
+                () => testRenderer.RenderRootComponentAsync(componentId));
+            Assert.StartsWith($"{typeof(TestInputComponent<string>)} requires a cascading parameter of type {nameof(EditContext)}", ex.Message);
+        }
+
+        [Fact]
+        public async Task ThrowsIfEditContextChanges()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty };
+            await RenderAndGetTestInputComponentAsync(rootComponent);
+
+            // Act/Assert
+            rootComponent.EditContext = new EditContext(model);
+            var ex = Assert.Throws<InvalidOperationException>(() => rootComponent.TriggerRender());
+            Assert.StartsWith($"{typeof(TestInputComponent<string>)} does not support changing the EditContext dynamically", ex.Message);
+        }
+
+        [Fact]
+        public async Task ThrowsIfNoValueExpressionIsSupplied()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model) };
+
+            // Act/Assert
+            var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => RenderAndGetTestInputComponentAsync(rootComponent));
+            Assert.Contains($"{typeof(TestInputComponent<string>)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message);
+        }
+
+        [Fact]
+        public async Task GetsCurrentValueFromValueParameter()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "some value",
+                ValueExpression = () => model.StringProperty
+            };
+
+            // Act
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+
+            // Assert
+            Assert.Equal("some value", inputComponent.CurrentValue);
+        }
+
+        [Fact]
+        public async Task ExposesIdToSubclass()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                Id = "test-id",
+                EditContext = new EditContext(model),
+                ValueExpression = () => model.StringProperty
+            };
+
+            // Act
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+
+            // Assert
+            Assert.Same(rootComponent.Id, inputComponent.Id);
+        }
+
+        [Fact]
+        public async Task ExposesEditContextToSubclass()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "some value",
+                ValueExpression = () => model.StringProperty
+            };
+
+            // Act
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+
+            // Assert
+            Assert.Same(rootComponent.EditContext, inputComponent.EditContext);
+        }
+
+        [Fact]
+        public async Task ExposesFieldIdentifierToSubclass()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "some value",
+                ValueExpression = () => model.StringProperty
+            };
+
+            // Act
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+
+            // Assert
+            Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier);
+        }
+
+        [Fact]
+        public async Task CanReadBackChangesToCurrentValue()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "initial value",
+                ValueExpression = () => model.StringProperty
+            };
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            Assert.Equal("initial value", inputComponent.CurrentValue);
+
+            // Act
+            inputComponent.CurrentValue = "new value";
+
+            // Assert
+            Assert.Equal("new value", inputComponent.CurrentValue);
+        }
+
+        [Fact]
+        public async Task WritingToCurrentValueInvokesValueChangedIfDifferent()
+        {
+            // Arrange
+            var model = new TestModel();
+            var valueChangedCallLog = new List<string>();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "initial value",
+                ValueChanged = val => valueChangedCallLog.Add(val),
+                ValueExpression = () => model.StringProperty
+            };
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            Assert.Empty(valueChangedCallLog);
+
+            // Act
+            inputComponent.CurrentValue = "new value";
+
+            // Assert
+            Assert.Single(valueChangedCallLog, "new value");
+        }
+
+        [Fact]
+        public async Task WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged()
+        {
+            // Arrange
+            var model = new TestModel();
+            var valueChangedCallLog = new List<string>();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "initial value",
+                ValueChanged = val => valueChangedCallLog.Add(val),
+                ValueExpression = () => model.StringProperty
+            };
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            Assert.Empty(valueChangedCallLog);
+
+            // Act
+            inputComponent.CurrentValue = "initial value";
+
+            // Assert
+            Assert.Empty(valueChangedCallLog);
+        }
+
+        [Fact]
+        public async Task WritingToCurrentValueNotifiesEditContext()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                Value = "initial value",
+                ValueExpression = () => model.StringProperty
+            };
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            Assert.False(rootComponent.EditContext.IsModified(() => model.StringProperty));
+
+            // Act
+            inputComponent.CurrentValue = "new value";
+
+            // Assert
+            Assert.True(rootComponent.EditContext.IsModified(() => model.StringProperty));
+        }
+
+        [Fact]
+        public async Task SuppliesFieldClassCorrespondingToFieldState()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                EditContext = new EditContext(model),
+                ValueExpression = () => model.StringProperty
+            };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
+
+            // Act/Assert: Initally, it's valid and unmodified
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            Assert.Equal("valid", inputComponent.FieldClass);
+            Assert.Equal("valid", inputComponent.CssClass); // Same because no Class was specified
+
+            // Act/Assert: Modify the field
+            rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier);
+            Assert.Equal("modified valid", inputComponent.FieldClass);
+            Assert.Equal("modified valid", inputComponent.CssClass);
+
+            // Act/Assert: Make it invalid
+            var messages = new ValidationMessageStore(rootComponent.EditContext);
+            messages.Add(fieldIdentifier, "I do not like this value");
+            Assert.Equal("modified invalid", inputComponent.FieldClass);
+            Assert.Equal("modified invalid", inputComponent.CssClass);
+
+            // Act/Assert: Clear the modification flag
+            rootComponent.EditContext.MarkAsUnmodified(fieldIdentifier);
+            Assert.Equal("invalid", inputComponent.FieldClass);
+            Assert.Equal("invalid", inputComponent.CssClass);
+
+            // Act/Assert: Make it valid
+            messages.Clear();
+            Assert.Equal("valid", inputComponent.FieldClass);
+            Assert.Equal("valid", inputComponent.CssClass);
+        }
+
+        [Fact]
+        public async Task CssClassCombinesClassWithFieldClass()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
+            {
+                Class = "my-class other-class",
+                EditContext = new EditContext(model),
+                ValueExpression = () => model.StringProperty
+            };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
+
+            // Act/Assert
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            Assert.Equal("valid", inputComponent.FieldClass);
+            Assert.Equal("my-class other-class valid", inputComponent.CssClass);
+
+            // Act/Assert: Retains custom class when changing field class
+            rootComponent.EditContext.NotifyFieldChanged(fieldIdentifier);
+            Assert.Equal("modified valid", inputComponent.FieldClass);
+            Assert.Equal("my-class other-class modified valid", inputComponent.CssClass);
+        }
+
+        [Fact]
+        public async Task SuppliesCurrentValueAsStringWithFormatting()
+        {
+            // Arrange
+            var model = new TestModel();
+            var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
+            {
+                EditContext = new EditContext(model),
+                Value = new DateTime(1915, 3, 2),
+                ValueExpression = () => model.DateProperty
+            };
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+
+            // Act/Assert
+            Assert.Equal("1915/03/02", inputComponent.CurrentValueAsString);
+        }
+
+        [Fact]
+        public async Task ParsesCurrentValueAsStringWhenChanged_Valid()
+        {
+            // Arrange
+            var model = new TestModel();
+            var valueChangedArgs = new List<DateTime>();
+            var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
+            {
+                EditContext = new EditContext(model),
+                ValueChanged = valueChangedArgs.Add,
+                ValueExpression = () => model.DateProperty
+            };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            var numValidationStateChanges = 0;
+            rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
+
+            // Act
+            inputComponent.CurrentValueAsString = "1991/11/20";
+
+            // Assert
+            var receivedParsedValue = valueChangedArgs.Single();
+            Assert.Equal(1991, receivedParsedValue.Year);
+            Assert.Equal(11, receivedParsedValue.Month);
+            Assert.Equal(20, receivedParsedValue.Day);
+            Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
+            Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
+            Assert.Equal(0, numValidationStateChanges);
+        }
+
+        [Fact]
+        public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
+        {
+            // Arrange
+            var model = new TestModel();
+            var valueChangedArgs = new List<DateTime>();
+            var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
+            {
+                EditContext = new EditContext(model),
+                ValueChanged = valueChangedArgs.Add,
+                ValueExpression = () => model.DateProperty
+            };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
+            var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
+            var numValidationStateChanges = 0;
+            rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
+
+            // Act/Assert 1: Transition to invalid
+            inputComponent.CurrentValueAsString = "1991/11/40";
+            Assert.Empty(valueChangedArgs);
+            Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
+            Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
+            Assert.Equal(1, numValidationStateChanges);
+
+            // Act/Assert 2: Transition to valid
+            inputComponent.CurrentValueAsString = "1991/11/20";
+            var receivedParsedValue = valueChangedArgs.Single();
+            Assert.Equal(1991, receivedParsedValue.Year);
+            Assert.Equal(11, receivedParsedValue.Month);
+            Assert.Equal(20, receivedParsedValue.Day);
+            Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
+            Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
+            Assert.Equal(2, numValidationStateChanges);
+        }
+
+        private static TComponent FindComponent<TComponent>(CapturedBatch batch)
+            => batch.ReferenceFrames
+                    .Where(f => f.FrameType == RenderTreeFrameType.Component)
+                    .Select(f => f.Component)
+                    .OfType<TComponent>()
+                    .Single();
+
+        private static async Task<TComponent> RenderAndGetTestInputComponentAsync<TValue, TComponent>(TestInputHostComponent<TValue, TComponent> hostComponent) where TComponent: TestInputComponent<TValue>
+        {
+            var testRenderer = new TestRenderer();
+            var componentId = testRenderer.AssignRootComponentId(hostComponent);
+            await testRenderer.RenderRootComponentAsync(componentId);
+            return FindComponent<TComponent>(testRenderer.Batches.Single());
+        }
+
+        class TestModel
+        {
+            public string StringProperty { get; set; }
+
+            public DateTime DateProperty { get; set; }
+        }
+
+        class TestInputComponent<T> : InputBase<T>
+        {
+            // Expose protected members publicly for tests
+
+            public new T CurrentValue
+            {
+                get => base.CurrentValue;
+                set { base.CurrentValue = value; }
+            }
+
+            public new string CurrentValueAsString
+            {
+                get => base.CurrentValueAsString;
+                set { base.CurrentValueAsString = value; }
+            }
+
+            public new string Id => base.Id;
+
+            public new string CssClass => base.CssClass;
+
+            public new EditContext EditContext => base.EditContext;
+
+            public new FieldIdentifier FieldIdentifier => base.FieldIdentifier;
+
+            public new string FieldClass => base.FieldClass;
+
+            protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage)
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        class TestDateInputComponent : TestInputComponent<DateTime>
+        {
+            protected override string FormatValueAsString(DateTime value)
+                => value.ToString("yyyy/MM/dd");
+
+            protected override bool TryParseValueFromString(string value, out DateTime result, out string validationErrorMessage)
+            {
+                if (DateTime.TryParse(value, out result))
+                {
+                    validationErrorMessage = null;
+                    return true;
+                }
+                else
+                {
+                    validationErrorMessage = "Bad date value";
+                    return false;
+                }
+            }
+        }
+
+        class TestInputHostComponent<TValue, TComponent> : AutoRenderComponent where TComponent: TestInputComponent<TValue>
+        {
+            public string Id { get; set; }
+
+            public string Class { get; set; }
+
+            public EditContext EditContext { get; set; }
+
+            public TValue Value { get; set; }
+
+            public Action<TValue> ValueChanged { get; set; }
+
+            public Expression<Func<TValue>> ValueExpression { get; set; }
+
+            protected override void BuildRenderTree(RenderTreeBuilder builder)
+            {
+                builder.OpenComponent<CascadingValue<EditContext>>(0);
+                builder.AddAttribute(1, "Value", EditContext);
+                builder.AddAttribute(2, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder =>
+                {
+                    childBuilder.OpenComponent<TComponent>(0);
+                    childBuilder.AddAttribute(0, "Value", Value);
+                    childBuilder.AddAttribute(1, "ValueChanged", ValueChanged);
+                    childBuilder.AddAttribute(2, "ValueExpression", ValueExpression);
+                    childBuilder.AddAttribute(3, nameof(Id), Id);
+                    childBuilder.AddAttribute(4, nameof(Class), Class);
+                    childBuilder.CloseComponent();
+                }));
+                builder.CloseComponent();
+            }
+        }
+    }
+}

+ 96 - 0
src/Components/Components/test/Forms/ValidationMessageStoreTest.cs

@@ -0,0 +1,96 @@
+// 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 Xunit;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    public class ValidationMessageStoreTest
+    {
+        [Fact]
+        public void CannotUseNullEditContext()
+        {
+            var ex = Assert.Throws<ArgumentNullException>(() => new ValidationMessageStore(null));
+            Assert.Equal("editContext", ex.ParamName);
+        }
+
+        [Fact]
+        public void CanCreateForEditContext()
+        {
+            new ValidationMessageStore(new EditContext(new object()));
+        }
+
+        [Fact]
+        public void CanAddMessages()
+        {
+            // Arrange
+            var messages = new ValidationMessageStore(new EditContext(new object()));
+            var field1 = new FieldIdentifier(new object(), "field1");
+            var field2 = new FieldIdentifier(new object(), "field2");
+            var field3 = new FieldIdentifier(new object(), "field3");
+            
+            // Act
+            messages.Add(field1, "Field 1 message 1");
+            messages.Add(field1, "Field 1 message 2");
+            messages.Add(field2, "Field 2 message 1");
+
+            // Assert
+            Assert.Equal(new[] { "Field 1 message 1", "Field 1 message 2" }, messages[field1]);
+            Assert.Equal(new[] { "Field 2 message 1" }, messages[field2]);
+            Assert.Empty(messages[field3]);
+        }
+
+        [Fact]
+        public void CanAddMessagesByRange()
+        {
+            // Arrange
+            var messages = new ValidationMessageStore(new EditContext(new object()));
+            var field1 = new FieldIdentifier(new object(), "field1");
+            var entries = new[] { "A", "B", "C" };
+
+            // Act
+            messages.AddRange(field1, entries);
+
+            // Assert
+            Assert.Equal(entries, messages[field1]);
+        }
+
+        [Fact]
+        public void CanClearMessagesForSingleField()
+        {
+            // Arrange
+            var messages = new ValidationMessageStore(new EditContext(new object()));
+            var field1 = new FieldIdentifier(new object(), "field1");
+            var field2 = new FieldIdentifier(new object(), "field2");
+            messages.Add(field1, "Field 1 message 1");
+            messages.Add(field1, "Field 1 message 2");
+            messages.Add(field2, "Field 2 message 1");
+
+            // Act
+            messages.Clear(field1);
+
+            // Assert
+            Assert.Empty(messages[field1]);
+            Assert.Equal(new[] { "Field 2 message 1" }, messages[field2]);
+        }
+
+        [Fact]
+        public void CanClearMessagesForAllFields()
+        {
+            // Arrange
+            var messages = new ValidationMessageStore(new EditContext(new object()));
+            var field1 = new FieldIdentifier(new object(), "field1");
+            var field2 = new FieldIdentifier(new object(), "field2");
+            messages.Add(field1, "Field 1 message 1");
+            messages.Add(field2, "Field 2 message 1");
+
+            // Act
+            messages.Clear();
+
+            // Assert
+            Assert.Empty(messages[field1]);
+            Assert.Empty(messages[field2]);
+        }
+    }
+}

+ 327 - 0
src/Components/test/E2ETest/Tests/FormsTest.cs

@@ -0,0 +1,327 @@
+// 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 OpenQA.Selenium;
+using OpenQA.Selenium.Support.UI;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Components.E2ETest.Tests
+{
+    public class FormsTest : BasicTestAppTestBase
+    {
+        public FormsTest(
+            BrowserFixture browserFixture,
+            ToggleExecutionModeServerFixture<Program> serverFixture,
+            ITestOutputHelper output)
+            : base(browserFixture, serverFixture, output)
+        {
+            // On WebAssembly, page reloads are expensive so skip if possible
+            Navigate(ServerPathBase, noReload: !serverFixture.UsingAspNetHost);
+        }
+
+        [Fact]
+        public async Task EditFormWorksWithDataAnnotationsValidator()
+        {
+            var appElement = MountTestComponent<SimpleValidationComponent>();
+            var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input"));
+            var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input"));
+            var submitButton = appElement.FindElement(By.TagName("button"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Editing a field doesn't trigger validation on its own
+            userNameInput.SendKeys("Bert\t");
+            acceptsTermsInput.Click(); // Accept terms
+            acceptsTermsInput.Click(); // Un-accept terms
+            await Task.Delay(500); // There's no expected change to the UI, so just wait a moment before asserting
+            WaitAssert.Empty(messagesAccessor);
+            Assert.Empty(appElement.FindElements(By.Id("last-callback")));
+
+            // Submitting the form does validate
+            submitButton.Click();
+            WaitAssert.Equal(new[] { "You must accept the terms" }, messagesAccessor);
+            WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text);
+
+            // Can make another field invalid
+            userNameInput.Clear();
+            submitButton.Click();
+            WaitAssert.Equal(new[] { "Please choose a username", "You must accept the terms" }, messagesAccessor);
+            WaitAssert.Equal("OnInvalidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text);
+
+            // Can make valid
+            userNameInput.SendKeys("Bert\t");
+            acceptsTermsInput.Click();
+            submitButton.Click();
+            WaitAssert.Empty(messagesAccessor);
+            WaitAssert.Equal("OnValidSubmit", () => appElement.FindElement(By.Id("last-callback")).Text);
+        }
+
+        [Fact]
+        public void InputTextInteractsWithEditContext()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var nameInput = appElement.FindElement(By.ClassName("name")).FindElement(By.TagName("input"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+            
+            // Validates on edit
+            WaitAssert.Equal("valid", () => nameInput.GetAttribute("class"));
+            nameInput.SendKeys("Bert\t");
+            WaitAssert.Equal("modified valid", () => nameInput.GetAttribute("class"));
+
+            // Can become invalid
+            nameInput.SendKeys("01234567890123456789\t");
+            WaitAssert.Equal("modified invalid", () => nameInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "That name is too long" }, messagesAccessor);
+
+            // Can become valid
+            nameInput.Clear();
+            nameInput.SendKeys("Bert\t");
+            WaitAssert.Equal("modified valid", () => nameInput.GetAttribute("class"));
+            WaitAssert.Empty(messagesAccessor);
+        }
+
+        [Fact]
+        public void InputNumberInteractsWithEditContext_NonNullableInt()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var ageInput = appElement.FindElement(By.ClassName("age")).FindElement(By.TagName("input"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => ageInput.GetAttribute("class"));
+            ageInput.SendKeys("123\t");
+            WaitAssert.Equal("modified valid", () => ageInput.GetAttribute("class"));
+
+            // Can become invalid
+            ageInput.SendKeys("e100\t");
+            WaitAssert.Equal("modified invalid", () => ageInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The AgeInYears field must be a number." }, messagesAccessor);
+
+            // Empty is invalid, because it's not a nullable int
+            ageInput.Clear();
+            ageInput.SendKeys("\t");
+            WaitAssert.Equal("modified invalid", () => ageInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The AgeInYears field must be a number." }, messagesAccessor);
+
+            // Zero is within the allowed range
+            ageInput.SendKeys("0\t");
+            WaitAssert.Equal("modified valid", () => ageInput.GetAttribute("class"));
+            WaitAssert.Empty(messagesAccessor);
+        }
+
+        [Fact]
+        public void InputNumberInteractsWithEditContext_NullableFloat()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var heightInput = appElement.FindElement(By.ClassName("height")).FindElement(By.TagName("input"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => heightInput.GetAttribute("class"));
+            heightInput.SendKeys("123.456\t");
+            WaitAssert.Equal("modified valid", () => heightInput.GetAttribute("class"));
+
+            // Can become invalid
+            heightInput.SendKeys("e100\t");
+            WaitAssert.Equal("modified invalid", () => heightInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The OptionalHeight field must be a number." }, messagesAccessor);
+
+            // Empty is valid, because it's a nullable float
+            heightInput.Clear();
+            heightInput.SendKeys("\t");
+            WaitAssert.Equal("modified valid", () => heightInput.GetAttribute("class"));
+            WaitAssert.Empty(messagesAccessor);
+        }
+
+        [Fact]
+        public void InputTextAreaInteractsWithEditContext()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var descriptionInput = appElement.FindElement(By.ClassName("description")).FindElement(By.TagName("textarea"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => descriptionInput.GetAttribute("class"));
+            descriptionInput.SendKeys("Hello\t");
+            WaitAssert.Equal("modified valid", () => descriptionInput.GetAttribute("class"));
+
+            // Can become invalid
+            descriptionInput.SendKeys("too long too long too long too long too long\t");
+            WaitAssert.Equal("modified invalid", () => descriptionInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "Description is max 20 chars" }, messagesAccessor);
+
+            // Can become valid
+            descriptionInput.Clear();
+            descriptionInput.SendKeys("Hello\t");
+            WaitAssert.Equal("modified valid", () => descriptionInput.GetAttribute("class"));
+            WaitAssert.Empty(messagesAccessor);
+        }
+
+        [Fact]
+        public void InputDateInteractsWithEditContext_NonNullableDateTime()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var renewalDateInput = appElement.FindElement(By.ClassName("renewal-date")).FindElement(By.TagName("input"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => renewalDateInput.GetAttribute("class"));
+            renewalDateInput.SendKeys("01/01/2000\t");
+            WaitAssert.Equal("modified valid", () => renewalDateInput.GetAttribute("class"));
+
+            // Can become invalid
+            renewalDateInput.SendKeys("0/0/0");
+            WaitAssert.Equal("modified invalid", () => renewalDateInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The RenewalDate field must be a date." }, messagesAccessor);
+
+            // Empty is invalid, because it's not nullable
+            renewalDateInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t");
+            WaitAssert.Equal("modified invalid", () => renewalDateInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The RenewalDate field must be a date." }, messagesAccessor);
+
+            // Can become valid
+            renewalDateInput.SendKeys("01/01/01\t");
+            WaitAssert.Equal("modified valid", () => renewalDateInput.GetAttribute("class"));
+            WaitAssert.Empty(messagesAccessor);
+        }
+
+        [Fact]
+        public void InputDateInteractsWithEditContext_NullableDateTimeOffset()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var expiryDateInput = appElement.FindElement(By.ClassName("expiry-date")).FindElement(By.TagName("input"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => expiryDateInput.GetAttribute("class"));
+            expiryDateInput.SendKeys("01/01/2000\t");
+            WaitAssert.Equal("modified valid", () => expiryDateInput.GetAttribute("class"));
+
+            // Can become invalid
+            expiryDateInput.SendKeys("111111111");
+            WaitAssert.Equal("modified invalid", () => expiryDateInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The OptionalExpiryDate field must be a date." }, messagesAccessor);
+
+            // Empty is valid, because it's nullable
+            expiryDateInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t");
+            WaitAssert.Equal("modified valid", () => expiryDateInput.GetAttribute("class"));
+            WaitAssert.Empty(messagesAccessor);
+        }
+
+        [Fact]
+        public void InputSelectInteractsWithEditContext()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var ticketClassInput = new SelectElement(appElement.FindElement(By.ClassName("ticket-class")).FindElement(By.TagName("select")));
+            var select = ticketClassInput.WrappedElement;
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => select.GetAttribute("class"));
+            ticketClassInput.SelectByText("First class");
+            WaitAssert.Equal("modified valid", () => select.GetAttribute("class"));
+
+            // Can become invalid
+            ticketClassInput.SelectByText("(select)");
+            WaitAssert.Equal("modified invalid", () => select.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "The TicketClass field is not valid." }, messagesAccessor);
+        }
+
+        [Fact]
+        public void InputCheckboxInteractsWithEditContext()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+            // Validates on edit
+            WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class"));
+            acceptsTermsInput.Click();
+            WaitAssert.Equal("modified valid", () => acceptsTermsInput.GetAttribute("class"));
+
+            // Can become invalid
+            acceptsTermsInput.Click();
+            WaitAssert.Equal("modified invalid", () => acceptsTermsInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "Must accept terms" }, messagesAccessor);
+        }
+
+        [Fact]
+        public void CanWireUpINotifyPropertyChangedToEditContext()
+        {
+            var appElement = MountTestComponent<NotifyPropertyChangedValidationComponent>();
+            var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input"));
+            var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input"));
+            var submitButton = appElement.FindElement(By.TagName("button"));
+            var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+            var submissionStatus = appElement.FindElement(By.Id("submission-status"));
+
+            // Editing a field triggers validation immediately
+            WaitAssert.Equal("valid", () => userNameInput.GetAttribute("class"));
+            userNameInput.SendKeys("Too long too long\t");
+            WaitAssert.Equal("modified invalid", () => userNameInput.GetAttribute("class"));
+            WaitAssert.Equal(new[] { "That name is too long" }, messagesAccessor);
+
+            // Submitting the form validates remaining fields
+            submitButton.Click();
+            WaitAssert.Equal(new[] { "That name is too long", "You must accept the terms" }, messagesAccessor);
+            WaitAssert.Equal("modified invalid", () => userNameInput.GetAttribute("class"));
+            WaitAssert.Equal("invalid", () => acceptsTermsInput.GetAttribute("class"));
+
+            // Can make fields valid
+            userNameInput.Clear();
+            userNameInput.SendKeys("Bert\t");
+            WaitAssert.Equal("modified valid", () => userNameInput.GetAttribute("class"));
+            acceptsTermsInput.Click();
+            WaitAssert.Equal("modified valid", () => acceptsTermsInput.GetAttribute("class"));
+            WaitAssert.Equal(string.Empty, () => submissionStatus.Text);
+            submitButton.Click();
+            WaitAssert.True(() => submissionStatus.Text.StartsWith("Submitted"));
+
+            // Fields can revert to unmodified
+            WaitAssert.Equal("valid", () => userNameInput.GetAttribute("class"));
+            WaitAssert.Equal("valid", () => acceptsTermsInput.GetAttribute("class"));
+        }
+
+        [Fact]
+        public void ValidationMessageDisplaysMessagesForField()
+        {
+            var appElement = MountTestComponent<TypicalValidationComponent>();
+            var emailContainer = appElement.FindElement(By.ClassName("email"));
+            var emailInput = emailContainer.FindElement(By.TagName("input"));
+            var emailMessagesAccessor = CreateValidationMessagesAccessor(emailContainer);
+            var submitButton = appElement.FindElement(By.TagName("button"));
+
+            // Doesn't show messages for other fields
+            submitButton.Click();
+            WaitAssert.Empty(emailMessagesAccessor);
+
+            // Updates on edit
+            emailInput.SendKeys("abc\t");
+            WaitAssert.Equal(new[] { "That doesn't look like a real email address" }, emailMessagesAccessor);
+
+            // Can show more than one message
+            emailInput.SendKeys("too long too long too long\t");
+            WaitAssert.Equal(new[] { "That doesn't look like a real email address", "We only accept very short email addresses (max 10 chars)" }, emailMessagesAccessor);
+
+            // Can become valid
+            emailInput.Clear();
+            emailInput.SendKeys("[email protected]\t");
+            WaitAssert.Empty(emailMessagesAccessor);
+        }
+
+        private Func<string[]> CreateValidationMessagesAccessor(IWebElement appElement)
+        {
+            return () => appElement.FindElements(By.ClassName("validation-message"))
+                .Select(x => x.Text)
+                .OrderBy(x => x)
+                .ToArray();
+        }
+    }
+}

+ 2 - 1
src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
     <TargetFramework>netstandard2.0</TargetFramework>
@@ -9,6 +9,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <Reference Include="System.ComponentModel" />
     <Reference Include="Microsoft.AspNetCore.Blazor" />
     <Reference Include="Microsoft.NET.Sdk.Razor" PrivateAssets="All" />
   </ItemGroup>

+ 105 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.cshtml

@@ -0,0 +1,105 @@
+@using System.ComponentModel
+@using System.ComponentModel.DataAnnotations
+@using System.Runtime.CompilerServices;
+@using Microsoft.AspNetCore.Components.Forms
+
+<p>
+    There's no requirement for models to implement INotifyPropertyChanged, but if they do,
+    you can easily wire that up to the EditContext. Then you have no need to use the built-in
+    Input* components - you can instead bind to regular HTML elements and still get modification
+    notifications. This provides more flexibility in how the UI is rendered, at the cost of
+    more complexity and boilerplate in your model classes.
+</p>
+<p>
+    This example also shows that you don't strictly have to use EditForm. You can manually
+    cascade an EditContext to the components that integrate with it. 
+</p>
+
+<form onsubmit="@HandleSubmit">
+    <p class="user-name">
+        User name:
+        <input bind="@person.UserName" class="@editContext.FieldClass(() => person.UserName)" />
+    </p>
+    <p class="accepts-terms">
+        Accept terms:
+        <input type="checkbox" bind="@person.AcceptsTerms" class="@editContext.FieldClass(() => person.AcceptsTerms)" />
+    </p>
+
+    <button type="submit">Submit</button>
+
+    <CascadingValue Value="@editContext" IsFixed="true">
+        <ValidationSummary />
+    </CascadingValue>
+</form>
+
+<div id="submission-status">@submissionStatus</div>
+
+@functions {
+    MyModel person = new MyModel();
+    EditContext editContext;
+    string submissionStatus;
+
+    protected override void OnInit()
+    {
+        editContext = new EditContext(person).AddDataAnnotationsValidation();
+
+        // Wire up INotifyPropertyChanged to the EditContext
+        person.PropertyChanged += (sender, eventArgs) =>
+        {
+            var fieldIdentifier = new FieldIdentifier(sender, eventArgs.PropertyName);
+            editContext.NotifyFieldChanged(fieldIdentifier);
+        };
+    }
+
+    void HandleSubmit()
+    {
+        if (editContext.Validate())
+        {
+            submissionStatus = $"Submitted at {DateTime.Now.ToLongTimeString()}";
+            editContext.MarkAsUnmodified();
+        }
+    }
+
+    class MyModel : INotifyPropertyChanged
+    {
+        string _userName;
+        bool _acceptsTerms;
+
+        [Required, StringLength(10, ErrorMessage = "That name is too long")]
+        public string UserName
+        {
+            get => _userName;
+            set => SetProperty(ref _userName, value);
+        }
+
+        [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")]
+        public bool AcceptsTerms
+        {
+            get => _acceptsTerms;
+            set => SetProperty(ref _acceptsTerms, value);
+        }
+
+        #region INotifyPropertyChanged boilerplate
+
+        public event PropertyChangedEventHandler PropertyChanged;
+
+        void OnPropertyChanged([CallerMemberName] string propertyName = null)
+        {
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+        }
+
+        bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
+        {
+            if (Equals(storage, value))
+            {
+                return false;
+            }
+
+            storage = value;
+            OnPropertyChanged(propertyName);
+            return true;
+        }
+
+        #endregion
+    }
+}

+ 50 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.cshtml

@@ -0,0 +1,50 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+<EditForm Model="@this" OnValidSubmit="@(EventCallback.Factory.Create<EditContext>(this, HandleValidSubmit))" OnInvalidSubmit="@(EventCallback.Factory.Create<EditContext>(this, HandleInvalidSubmit))">
+    <DataAnnotationsValidator />
+
+    <p class="user-name">
+        User name: <input bind="@UserName" class="@context.FieldClass(() => UserName)" />
+    </p>
+    <p class="accepts-terms">
+        Accept terms: <input type="checkbox" bind="@AcceptsTerms" class="@context.FieldClass(() => AcceptsTerms)" />
+    </p>
+
+    <button type="submit">Submit</button>
+
+    @* Could use <ValidationSummary /> instead, but this shows it can be done manually *@
+    <ul class="validation-errors">
+        @foreach (var message in context.GetValidationMessages())
+        {
+            <li class="validation-message">@message</li>
+        }
+    </ul>
+
+</EditForm>
+
+@if (lastCallback != null)
+{
+    <span id="last-callback">@lastCallback</span>
+}
+
+@functions {
+    string lastCallback;
+
+    [Required(ErrorMessage = "Please choose a username")]
+    public string UserName { get; set; }
+
+    [Required]
+    [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms")]
+    public bool AcceptsTerms { get; set; }
+
+    void HandleValidSubmit()
+    {
+        lastCallback = "OnValidSubmit";
+    }
+
+    void HandleInvalidSubmit()
+    {
+        lastCallback = "OnInvalidSubmit";
+    }
+}

+ 89 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.cshtml

@@ -0,0 +1,89 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+<EditForm Model="@person" OnValidSubmit="@(EventCallback.Factory.Create<EditContext>(this, HandleValidSubmit))">
+    <DataAnnotationsValidator />
+
+    <p class="name">
+        Name: <InputText bind-Value="@person.Name" ValueExpression="@(() => person.Name)" />
+    </p>
+    <p class="email">
+        Email: <InputText bind-Value="@person.Email" ValueExpression="@(() => person.Email)" />
+        <ValidationMessage For="@(() => person.Email)" />
+    </p>
+    <p class="age">
+        Age (years): <InputNumber bind-Value="@person.AgeInYears" ValueExpression="@(() => person.AgeInYears)" />
+    </p>
+    <p class="height">
+        Height (optional): <InputNumber bind-Value="@person.OptionalHeight" ValueExpression="@(() => person.OptionalHeight)" />
+    </p>
+    <p class="description">
+        Description: <InputTextArea bind-Value="@person.Description" ValueExpression="@(() => person.Description)" />
+    </p>
+    <p class="renewal-date">
+        Renewal date: <InputDate bind-Value="@person.RenewalDate" ValueExpression="@(() => person.RenewalDate)" />
+    </p>
+    <p class="expiry-date">
+        Expiry date (optional): <InputDate bind-Value="@person.OptionalExpiryDate" ValueExpression="@(() => person.OptionalExpiryDate)" />
+    </p>
+    <p class="ticket-class">
+        Ticket class:
+        <InputSelect bind-Value="@person.TicketClass" ValueExpression="@(() => person.TicketClass)">
+            <option>(select)</option>
+            <option value="@TicketClass.Economy">Economy class</option>
+            <option value="@TicketClass.Premium">Premium class</option>
+            <option value="@TicketClass.First">First class</option>
+        </InputSelect>
+    </p>
+    <p class="accepts-terms">
+        Accepts terms: <InputCheckbox bind-Value="@person.AcceptsTerms" ValueExpression="@(() => person.AcceptsTerms)" />
+    </p>
+
+    <button type="submit">Submit</button>
+
+    <ValidationSummary />
+</EditForm>
+
+<ul>@foreach (var entry in submissionLog) { <li>@entry</li> }</ul>
+
+@functions {
+    Person person = new Person();
+
+    // Usually this would be in a different file
+    class Person
+    {
+        [Required(ErrorMessage = "Enter a name"), StringLength(10, ErrorMessage = "That name is too long")]
+        public string Name { get; set; }
+
+        [EmailAddress(ErrorMessage = "That doesn't look like a real email address")]
+        [StringLength(10, ErrorMessage = "We only accept very short email addresses (max 10 chars)")]
+        public string Email { get; set; }
+
+        [Range(0, 200, ErrorMessage = "Nobody is that old")]
+        public int AgeInYears { get; set; }
+
+        public float? OptionalHeight { get; set; }
+
+        public DateTime RenewalDate { get; set; } = DateTime.Now;
+
+        public DateTimeOffset? OptionalExpiryDate { get; set; }
+
+        [Required, Range(typeof(bool), "true", "true", ErrorMessage = "Must accept terms")]
+        public bool AcceptsTerms { get; set; }
+
+        [Required, StringLength(20, ErrorMessage = "Description is max 20 chars")]
+        public string Description { get; set; }
+
+        [Required, EnumDataType(typeof(TicketClass))]
+        public TicketClass TicketClass { get; set; }
+    }
+
+    enum TicketClass { Economy, Premium, First }
+
+    List<string> submissionLog = new List<string>(); // So we can assert about the callbacks
+
+    void HandleValidSubmit()
+    {
+        submissionLog.Add("OnValidSubmit");
+    }
+}

+ 3 - 0
src/Components/test/testassets/BasicTestApp/Index.cshtml

@@ -46,6 +46,9 @@
         <option value="BasicTestApp.ConcurrentRenderParent">Concurrent rendering</option>
         <option value="BasicTestApp.DispatchingComponent">Dispatching to sync context</option>
         <option value="BasicTestApp.EventCallbackTest.EventCallbackCases">EventCallback</option>
+        <option value="BasicTestApp.FormsTest.SimpleValidationComponent">Simple validation</option>
+        <option value="BasicTestApp.FormsTest.TypicalValidationComponent">Typical validation</option>
+        <option value="BasicTestApp.FormsTest.NotifyPropertyChangedValidationComponent">INotifyPropertyChanged validation</option>
     </select>
 
   @if (SelectedComponentType != null)

+ 2 - 1
src/Components/test/testassets/BasicTestApp/wwwroot/index.html

@@ -3,7 +3,8 @@
 <head>
     <meta charset="utf-8" />
     <title>Basic test app</title>
-    <base href="/subdir/" />
+    <base href="/subdir/" />    
+    <link href="style.css" rel="stylesheet" />
 </head>
 <body>
   <root>Loading...</root>

+ 11 - 0
src/Components/test/testassets/BasicTestApp/wwwroot/style.css

@@ -0,0 +1,11 @@
+.modified.valid {
+    box-shadow: 0px 0px 0px 2px rgb(78, 203, 37);
+}
+
+.invalid {
+    box-shadow: 0px 0px 0px 2px rgb(255, 0, 0);
+}
+
+.validation-message {
+    color: red;
+}