Support @bind for textboxes and checkboxes
This commit is contained in:
parent
02a0be5c2b
commit
6995b974e9
|
|
@ -186,7 +186,11 @@ export class BrowserRenderer {
|
||||||
}
|
}
|
||||||
case 'onchange': {
|
case 'onchange': {
|
||||||
toDomElement.removeEventListener('change', toDomElement['_blazorChangeListener']);
|
toDomElement.removeEventListener('change', toDomElement['_blazorChangeListener']);
|
||||||
const listener = evt => raiseEvent(evt, browserRendererId, componentId, eventHandlerId, 'change', { Type: 'change', Value: evt.target.value });
|
const targetIsCheckbox = isCheckbox(toDomElement);
|
||||||
|
const listener = evt => {
|
||||||
|
const newValue = targetIsCheckbox ? evt.target.checked : evt.target.value;
|
||||||
|
raiseEvent(evt, browserRendererId, componentId, eventHandlerId, 'change', { Type: 'change', Value: newValue });
|
||||||
|
};
|
||||||
toDomElement['_blazorChangeListener'] = listener;
|
toDomElement['_blazorChangeListener'] = listener;
|
||||||
toDomElement.addEventListener('change', listener);
|
toDomElement.addEventListener('change', listener);
|
||||||
break;
|
break;
|
||||||
|
|
@ -218,8 +222,13 @@ export class BrowserRenderer {
|
||||||
// Certain elements have built-in behaviour for their 'value' property
|
// Certain elements have built-in behaviour for their 'value' property
|
||||||
switch (element.tagName) {
|
switch (element.tagName) {
|
||||||
case 'INPUT':
|
case 'INPUT':
|
||||||
case 'SELECT': // Note: this doen't handle <select> correctly: https://github.com/aspnet/Blazor/issues/157
|
case 'SELECT':
|
||||||
(element as any).value = value;
|
if (isCheckbox(element)) {
|
||||||
|
(element as HTMLInputElement).checked = value === 'True';
|
||||||
|
} else {
|
||||||
|
// Note: this doen't handle <select> correctly: https://github.com/aspnet/Blazor/issues/157
|
||||||
|
(element as any).value = value;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -244,6 +253,10 @@ export class BrowserRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCheckbox(element: Element) {
|
||||||
|
return element.tagName === 'INPUT' && element.getAttribute('type') === 'checkbox';
|
||||||
|
}
|
||||||
|
|
||||||
function insertNodeIntoDOM(node: Node, parent: Element, childIndex: number) {
|
function insertNodeIntoDOM(node: Node, parent: Element, childIndex: number) {
|
||||||
if (childIndex >= parent.childNodes.length) {
|
if (childIndex >= parent.childNodes.length) {
|
||||||
parent.appendChild(node);
|
parent.appendChild(node);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using AngleSharp;
|
using AngleSharp;
|
||||||
using AngleSharp.Html;
|
using AngleSharp.Html;
|
||||||
using AngleSharp.Parser.Html;
|
using AngleSharp.Parser.Html;
|
||||||
|
|
@ -21,10 +22,11 @@ namespace Microsoft.AspNetCore.Blazor.Razor
|
||||||
{
|
{
|
||||||
// Per the HTML spec, the following elements are inherently self-closing
|
// Per the HTML spec, the following elements are inherently self-closing
|
||||||
// For example, <img> is the same as <img /> (and therefore it cannot contain descendants)
|
// For example, <img> is the same as <img /> (and therefore it cannot contain descendants)
|
||||||
private static HashSet<string> htmlVoidElementsLookup
|
private readonly static HashSet<string> htmlVoidElementsLookup
|
||||||
= new HashSet<string>(
|
= new HashSet<string>(
|
||||||
new[] { "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" },
|
new[] { "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" },
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly static Regex bindExpressionRegex = new Regex(@"^bind\((.+)\)$");
|
||||||
|
|
||||||
private readonly ScopeStack _scopeStack = new ScopeStack();
|
private readonly ScopeStack _scopeStack = new ScopeStack();
|
||||||
private string _unconsumedHtml;
|
private string _unconsumedHtml;
|
||||||
|
|
@ -269,12 +271,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor
|
||||||
{
|
{
|
||||||
foreach (var token in _currentElementAttributeTokens)
|
foreach (var token in _currentElementAttributeTokens)
|
||||||
{
|
{
|
||||||
codeWriter
|
WriteElementAttributeToken(context, nextTag, token);
|
||||||
.WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}")
|
|
||||||
.Write((_sourceSequence++).ToString())
|
|
||||||
.WriteParameterSeparator()
|
|
||||||
.Write(token.AttributeValue.Content)
|
|
||||||
.WriteEndMethodInvocation();
|
|
||||||
}
|
}
|
||||||
_currentElementAttributeTokens.Clear();
|
_currentElementAttributeTokens.Clear();
|
||||||
}
|
}
|
||||||
|
|
@ -321,6 +318,46 @@ namespace Microsoft.AspNetCore.Blazor.Razor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void WriteElementAttributeToken(CodeRenderingContext context, HtmlTagToken tag, PendingAttributeToken token)
|
||||||
|
{
|
||||||
|
var bindMatch = bindExpressionRegex.Match(token.AttributeValue.Content);
|
||||||
|
if (bindMatch.Success)
|
||||||
|
{
|
||||||
|
// The @bind(X) syntax is special. We convert it to a pair of attributes:
|
||||||
|
// [1] value=@X
|
||||||
|
var valueExpression = bindMatch.Groups[1].Value;
|
||||||
|
WriteAttribute(context.CodeWriter, "value",
|
||||||
|
new IntermediateToken { Kind = TokenKind.CSharp, Content = valueExpression });
|
||||||
|
|
||||||
|
// [2] @onchange(newValue => { X = newValue; })
|
||||||
|
var isCheckbox = tag.Data.Equals("input", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& tag.Attributes.Any(a =>
|
||||||
|
a.Key.Equals("type", StringComparison.Ordinal)
|
||||||
|
&& a.Value.Equals("checkbox", StringComparison.Ordinal));
|
||||||
|
var castToType = isCheckbox ? "bool" : "string";
|
||||||
|
var onChangeAttributeToken = new PendingAttributeToken
|
||||||
|
{
|
||||||
|
AttributeValue = new IntermediateToken
|
||||||
|
{
|
||||||
|
Kind = TokenKind.CSharp,
|
||||||
|
Content = $"onchange(_newValue_ => {{ {valueExpression} = ({castToType})_newValue_; }})"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
WriteElementAttributeToken(context, tag, onChangeAttributeToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For any other attribute token (e.g., @onclick(...)), treat it as an expression
|
||||||
|
// that will evaluate as an attribute frame
|
||||||
|
context.CodeWriter
|
||||||
|
.WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}")
|
||||||
|
.Write((_sourceSequence++).ToString())
|
||||||
|
.WriteParameterSeparator()
|
||||||
|
.Write(token.AttributeValue.Content)
|
||||||
|
.WriteEndMethodInvocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ThrowTemporaryComponentSyntaxError(HtmlContentIntermediateNode node, HtmlTagToken tag, string componentName)
|
private void ThrowTemporaryComponentSyntaxError(HtmlContentIntermediateNode node, HtmlTagToken tag, string componentName)
|
||||||
=> throw new RazorCompilerException(
|
=> throw new RazorCompilerException(
|
||||||
$"Wrong syntax for '{tag.Attributes[0].Key}' on '{componentName}': As a temporary " +
|
$"Wrong syntax for '{tag.Attributes[0].Key}' on '{componentName}': As a temporary " +
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,13 @@ namespace Microsoft.AspNetCore.Blazor.Components
|
||||||
public virtual Task ExecuteAsync()
|
public virtual Task ExecuteAsync()
|
||||||
=> throw new NotImplementedException($"Blazor components do not implement {nameof(ExecuteAsync)}.");
|
=> throw new NotImplementedException($"Blazor components do not implement {nameof(ExecuteAsync)}.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies two-way data binding between the element and the property.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The model property to be bound to the element.</param>
|
||||||
|
protected RenderTreeFrame bind(object value)
|
||||||
|
=> throw new NotImplementedException($"{nameof(bind)} is a compile-time symbol only and should not be invoked.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles click events by invoking <paramref name="handler"/>.
|
/// Handles click events by invoking <paramref name="handler"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,67 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
frame => AssertFrame.Whitespace(frame, 2));
|
frame => AssertFrame.Whitespace(frame, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SupportsTwoWayBindingForTextboxes()
|
||||||
|
{
|
||||||
|
// Arrange/Act
|
||||||
|
var component = CompileToComponent(
|
||||||
|
@"<input @bind(MyValue) />
|
||||||
|
@functions {
|
||||||
|
public string MyValue { get; set; } = ""Initial value"";
|
||||||
|
}");
|
||||||
|
var myValueProperty = component.GetType().GetProperty("MyValue");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var frames = GetRenderTree(component);
|
||||||
|
Assert.Collection(frames,
|
||||||
|
frame => AssertFrame.Element(frame, "input", 3, 0),
|
||||||
|
frame => AssertFrame.Attribute(frame, "value", "Initial value", 1),
|
||||||
|
frame =>
|
||||||
|
{
|
||||||
|
AssertFrame.Attribute(frame, "onchange", 2);
|
||||||
|
|
||||||
|
// Trigger the change event to show it updates the property
|
||||||
|
((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs
|
||||||
|
{
|
||||||
|
Value = "Modified value"
|
||||||
|
});
|
||||||
|
Assert.Equal("Modified value", myValueProperty.GetValue(component));
|
||||||
|
},
|
||||||
|
frame => AssertFrame.Text(frame, "\n", 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SupportsTwoWayBindingForCheckboxes()
|
||||||
|
{
|
||||||
|
// Arrange/Act
|
||||||
|
var component = CompileToComponent(
|
||||||
|
@"<input @bind(MyValue) type=""checkbox"" />
|
||||||
|
@functions {
|
||||||
|
public bool MyValue { get; set; } = true;
|
||||||
|
}");
|
||||||
|
var myValueProperty = component.GetType().GetProperty("MyValue");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var frames = GetRenderTree(component);
|
||||||
|
Assert.Collection(frames,
|
||||||
|
frame => AssertFrame.Element(frame, "input", 4, 0),
|
||||||
|
frame => AssertFrame.Attribute(frame, "type", "checkbox", 1),
|
||||||
|
frame => AssertFrame.Attribute(frame, "value", "True", 2),
|
||||||
|
frame =>
|
||||||
|
{
|
||||||
|
AssertFrame.Attribute(frame, "onchange", 3);
|
||||||
|
|
||||||
|
// Trigger the change event to show it updates the property
|
||||||
|
((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs
|
||||||
|
{
|
||||||
|
Value = false
|
||||||
|
});
|
||||||
|
Assert.False((bool)myValueProperty.GetValue(component));
|
||||||
|
},
|
||||||
|
frame => AssertFrame.Text(frame, "\n", 4));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SupportsChildComponentsViaTemporarySyntax()
|
public void SupportsChildComponentsViaTemporarySyntax()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
// 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 Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure.ServerFixtures;
|
||||||
|
using OpenQA.Selenium;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
|
||||||
|
{
|
||||||
|
public class BindTest : BasicTestAppTestBase
|
||||||
|
{
|
||||||
|
public BindTest(BrowserFixture browserFixture, DevHostServerFixture<Program> serverFixture)
|
||||||
|
: base(browserFixture, serverFixture)
|
||||||
|
{
|
||||||
|
Navigate(ServerPathBase, noReload: true);
|
||||||
|
MountTestComponent<BindCasesComponent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBindTextbox_InitiallyBlank()
|
||||||
|
{
|
||||||
|
var target = Browser.FindElement(By.Id("textbox-initially-blank"));
|
||||||
|
var boundValue = Browser.FindElement(By.Id("textbox-initially-blank-value"));
|
||||||
|
Assert.Equal(string.Empty, target.GetAttribute("value"));
|
||||||
|
Assert.Equal(string.Empty, boundValue.Text);
|
||||||
|
|
||||||
|
// Modify target; verify value is updated
|
||||||
|
target.SendKeys("Changed value");
|
||||||
|
Assert.Equal(string.Empty, boundValue.Text); // Doesn't update until change event
|
||||||
|
target.SendKeys("\t");
|
||||||
|
Assert.Equal("Changed value", boundValue.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBindTextbox_InitiallyPopulated()
|
||||||
|
{
|
||||||
|
var target = Browser.FindElement(By.Id("textbox-initially-populated"));
|
||||||
|
var boundValue = Browser.FindElement(By.Id("textbox-initially-populated-value"));
|
||||||
|
Assert.Equal("Hello", target.GetAttribute("value"));
|
||||||
|
Assert.Equal("Hello", boundValue.Text);
|
||||||
|
|
||||||
|
// Modify target; verify value is updated
|
||||||
|
target.Clear();
|
||||||
|
target.SendKeys("Changed value\t");
|
||||||
|
Assert.Equal("Changed value", boundValue.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBindCheckbox_InitiallyUnchecked()
|
||||||
|
{
|
||||||
|
var target = Browser.FindElement(By.Id("checkbox-initially-unchecked"));
|
||||||
|
var boundValue = Browser.FindElement(By.Id("checkbox-initially-unchecked-value"));
|
||||||
|
Assert.False(target.Selected);
|
||||||
|
Assert.Equal("False", boundValue.Text);
|
||||||
|
|
||||||
|
// Modify target; verify value is updated
|
||||||
|
target.Click();
|
||||||
|
Assert.True(target.Selected);
|
||||||
|
Assert.Equal("True", boundValue.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanBindCheckbox_InitiallyChecked()
|
||||||
|
{
|
||||||
|
var target = Browser.FindElement(By.Id("checkbox-initially-checked"));
|
||||||
|
var boundValue = Browser.FindElement(By.Id("checkbox-initially-checked-value"));
|
||||||
|
Assert.True(target.Selected);
|
||||||
|
Assert.Equal("True", boundValue.Text);
|
||||||
|
|
||||||
|
// Modify target; verify value is updated
|
||||||
|
target.Click();
|
||||||
|
Assert.False(target.Selected);
|
||||||
|
Assert.Equal("False", boundValue.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<h2>Textbox</h2>
|
||||||
|
<p>
|
||||||
|
Initially blank:
|
||||||
|
<input id="textbox-initially-blank" @bind(textboxInitiallyBlankValue) />
|
||||||
|
<span id="textbox-initially-blank-value">@textboxInitiallyBlankValue</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Initially populated:
|
||||||
|
<input id="textbox-initially-populated" @bind(textboxInitiallyPopulatedValue) />
|
||||||
|
<span id="textbox-initially-populated-value">@textboxInitiallyPopulatedValue</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Checkbox</h2>
|
||||||
|
<p>
|
||||||
|
Initially unchecked:
|
||||||
|
<input id="checkbox-initially-unchecked" @bind(checkboxInitiallyUncheckedValue) type="checkbox" />
|
||||||
|
<span id="checkbox-initially-unchecked-value">@checkboxInitiallyUncheckedValue</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Initially checked:
|
||||||
|
<input id="checkbox-initially-checked" @bind(checkboxInitiallyCheckedValue) type="checkbox" />
|
||||||
|
<span id="checkbox-initially-checked-value">@checkboxInitiallyCheckedValue</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
string textboxInitiallyBlankValue = null;
|
||||||
|
string textboxInitiallyPopulatedValue = "Hello";
|
||||||
|
|
||||||
|
bool checkboxInitiallyUncheckedValue = false;
|
||||||
|
bool checkboxInitiallyCheckedValue = true;
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
||||||
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
|
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
|
||||||
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
|
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
|
||||||
|
<option value="BasicTestApp.BindCasesComponent">@bind cases</option>
|
||||||
<!--<option value="BasicTestApp.RouterTest.Default">Router</option> Excluded because it requires additional setup to work correctly when loaded manually -->
|
<!--<option value="BasicTestApp.RouterTest.Default">Router</option> Excluded because it requires additional setup to work correctly when loaded manually -->
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue