Skip to content

[release/6.0] Fix keyboard input for 'time' and 'datetime-local' inputs #35742

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions src/Components/Web.JS/src/Rendering/BrowserRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,20 +390,16 @@ export class BrowserRenderer {
// Certain elements have built-in behaviour for their 'value' property
const frameReader = batch.frameReader;

if (element.tagName === 'INPUT' && element.getAttribute('type') === 'time' && !element.getAttribute('step')) {
const timeValue = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
if (timeValue) {
element['value'] = timeValue.substring(0, 5);
return true;
}
let value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;

if (value && element.tagName === 'INPUT') {
value = normalizeInputValue(value, element.getAttribute('type'));
}

switch (element.tagName) {
case 'INPUT':
case 'SELECT':
case 'TEXTAREA': {
let value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;

// <select> is special, in that anything we write to .value will be lost if there
// isn't yet a matching <option>. To maintain the expected behavior no matter the
// element insertion/update order, preserve the desired value separately so
Expand All @@ -425,7 +421,6 @@ export class BrowserRenderer {
return true;
}
case 'OPTION': {
const value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
if (value || value === '') {
element.setAttribute('value', value);
} else {
Expand Down Expand Up @@ -495,6 +490,27 @@ function parseMarkup(markup: string, isSvg: boolean) {
}
}

function normalizeInputValue(value: string, type: string | null): string {
// Time inputs (e.g. 'time' and 'datetime-local') misbehave on chromium-based
// browsers when a time is set that includes a seconds value of '00', most notably
// when entered from keyboard input. This behavior is not limited to specific
// 'step' attribute values, so we always remove the trailing seconds value if the
// time ends in '00'.

switch (type) {
case 'time':
return value.length === 8 && value.endsWith('00')
? value.substring(0, 5)
: value;
case 'datetime-local':
return value.length === 19 && value.endsWith('00')
? value.substring(0, 16)
: value;
default:
return value;
}
}

function countDescendantFrames(batch: RenderBatch, frame: RenderTreeFrame): number {
const frameReader = batch.frameReader;
switch (frameReader.frameType(frame)) {
Expand Down
113 changes: 97 additions & 16 deletions src/Components/test/E2ETest/Tests/FormsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,15 @@ public void InputDateInteractsWithEditContext_NullableDateTimeOffset()
public void InputDateInteractsWithEditContext_TimeInput()
{
var appElement = MountTypicalValidationComponent();
var departureTimeInput = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.TagName("input"));
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
var departureTimeInput = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-input"));
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-seconds-checkbox"));

// Ensure we're not using a custom step
if (includeSecondsCheckbox.Selected)
{
includeSecondsCheckbox.Click();
}

// Validates on edit
Browser.Equal("valid", () => departureTimeInput.GetAttribute("class"));
Expand All @@ -271,7 +278,39 @@ public void InputDateInteractsWithEditContext_TimeInput()
Browser.Equal(new[] { "The DepartureTime field must be a time." }, messagesAccessor);
}

[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/35498")]
[Fact]
public void InputDateInteractsWithEditContext_TimeInput_Step()
{
var appElement = MountTypicalValidationComponent();
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
var departureTimeInput = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-input"));
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-seconds-checkbox"));

// Ensure we're using a custom step
if (!includeSecondsCheckbox.Selected)
{
includeSecondsCheckbox.Click();
}

// Input works with seconds value of zero and has the expected final value
Browser.Equal("valid", () => departureTimeInput.GetAttribute("class"));
departureTimeInput.SendKeys("111111");
Browser.Equal("modified valid", () => departureTimeInput.GetAttribute("class"));
Browser.Equal("11:11:11", () => departureTimeInput.GetAttribute("value"));

// Input works with non-zero seconds value
// Move to the beginning of the input and put the new time
departureTimeInput.SendKeys(string.Concat(Enumerable.Repeat(Keys.ArrowLeft, 3)) + "101010");
Browser.Equal("modified valid", () => departureTimeInput.GetAttribute("class"));
Browser.Equal("10:10:10", () => departureTimeInput.GetAttribute("value"));

// Can become invalid
departureTimeInput.SendKeys(Keys.Backspace);
Browser.Equal("modified invalid", () => departureTimeInput.GetAttribute("class"));
Browser.Equal(new[] { "The DepartureTime field must be a time." }, messagesAccessor);
}

[Fact]
public void InputDateInteractsWithEditContext_MonthInput()
{
var appElement = MountTypicalValidationComponent();
Expand All @@ -283,50 +322,92 @@ public void InputDateInteractsWithEditContext_MonthInput()
visitMonthInput.SendKeys($"03{Keys.ArrowRight}2005\t");
Browser.Equal("modified valid", () => visitMonthInput.GetAttribute("class"));

// Can become invalid
visitMonthInput.SendKeys($"11{Keys.ArrowRight}11111\t");
// Empty is invalid because it's not nullable
visitMonthInput.Clear();
Browser.Equal("modified invalid", () => visitMonthInput.GetAttribute("class"));
Browser.Equal(new[] { "The VisitMonth field must be a year and month." }, messagesAccessor);

// Empty is invalid, because it's not nullable
visitMonthInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t");
// Invalid year (11111)
visitMonthInput.SendKeys($"11{Keys.ArrowRight}11111\t");
Browser.Equal("modified invalid", () => visitMonthInput.GetAttribute("class"));
Browser.Equal(new[] { "The VisitMonth field must be a year and month." }, messagesAccessor);

// Can become valid again
visitMonthInput.Clear();
visitMonthInput.SendKeys($"05{Keys.ArrowRight}2007\t");
visitMonthInput.SendKeys($"11{Keys.ArrowRight}1111\t");
Browser.Equal("modified valid", () => visitMonthInput.GetAttribute("class"));
Browser.Empty(messagesAccessor);
}

[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/35498")]
[Fact]
[QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/34884")]
public void InputDateInteractsWithEditContext_DateTimeLocalInput()
{
var appElement = MountTypicalValidationComponent();
var appointmentInput = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.TagName("input"));
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
var appointmentInput = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-input"));
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-seconds-checkbox"));

// Validates on edit
// Ensure we're not using a custom step
if (includeSecondsCheckbox.Selected)
{
includeSecondsCheckbox.Click();
}

// Validates on edit and has the expected value
Browser.Equal("valid", () => appointmentInput.GetAttribute("class"));
appointmentInput.SendKeys("01\t02\t1988\t0523\t1");
appointmentInput.SendKeys($"01011970{Keys.ArrowRight}05421");
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));

// Can become invalid
appointmentInput.SendKeys($"11{Keys.ArrowRight}11{Keys.ArrowRight}11111{Keys.ArrowRight}\t");
// Empty is invalid because it's not nullable
appointmentInput.Clear();
Browser.Equal("modified invalid", () => appointmentInput.GetAttribute("class"));
Browser.Equal(new[] { "The AppointmentDateAndTime field must be a date and time." }, messagesAccessor);

// Empty is invalid, because it's not nullable
appointmentInput.SendKeys($"{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t{Keys.Backspace}\t");
// Invalid year (11111)
appointmentInput.SendKeys($"111111111{Keys.ArrowRight}11111");
Browser.Equal("modified invalid", () => appointmentInput.GetAttribute("class"));
Browser.Equal(new[] { "The AppointmentDateAndTime field must be a date and time." }, messagesAccessor);

appointmentInput.SendKeys("01234567\t11551\t");
// Can become valid again
appointmentInput.Clear();
appointmentInput.SendKeys($"11111111{Keys.ArrowRight}11111");
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
Browser.Empty(messagesAccessor);
}

[Fact]
public void InputDateInteractsWithEditContext_DateTimeLocalInput_Step()
{
var appElement = MountTypicalValidationComponent();
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
var appointmentInput = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-input"));
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-seconds-checkbox"));

// Ensure we're using a custom step
if (!includeSecondsCheckbox.Selected)
{
includeSecondsCheckbox.Click();
}

// Input works with seconds value of zero and has the expected final value
Browser.Equal("valid", () => appointmentInput.GetAttribute("class"));
appointmentInput.SendKeys($"11111970{Keys.ArrowRight}114216");
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
Browser.Equal("1970-11-11T11:42:16", () => appointmentInput.GetAttribute("value"));

// Input works with non-zero seconds value
// Move to the beginning of the input and put the new value
appointmentInput.SendKeys(string.Concat(Enumerable.Repeat(Keys.ArrowLeft, 6)) + $"10101970{Keys.ArrowRight}105321");
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
Browser.Equal("1970-10-10T10:53:21", () => appointmentInput.GetAttribute("value"));

// Can become invalid
appointmentInput.SendKeys(Keys.Backspace);
Browser.Equal("modified invalid", () => appointmentInput.GetAttribute("class"));
Browser.Equal(new[] { "The AppointmentDateAndTime field must be a date and time." }, messagesAccessor);
}

[Fact]
public void InputSelectInteractsWithEditContext()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,27 @@
Expiry date (optional): <InputDate @bind-Value="person.OptionalExpiryDate" />
</p>
<p class="departure-time">
Departure time: <InputDate Type="InputDateType.Time" @bind-Value="person.DepartureTime" placeholder="Enter the time" />
Departure time:<br>
<input id="time-seconds-checkbox" type="checkbox" @bind-value="includeTimeSeconds" />Include seconds<br>
<InputDate
id="time-input"
Type="InputDateType.Time"
@bind-Value="person.DepartureTime"
placeholder="Enter the time"
step="@(includeTimeSeconds ? 1 : 60)" />
</p>
<p class="visit-month">
Month of visit: <InputDate Type="InputDateType.Month" @bind-Value="person.VisitMonth" placeholder="Enter the month of your visit" />
</p>
<p class="appointment-date-time">
Appointment date and time: <InputDate Type="InputDateType.DateTimeLocal" @bind-Value="person.AppointmentDateAndTime" placeholder="Enter the appointment date and time" />
Appointment date and time:<br>
<input id="datetime-local-seconds-checkbox" type="checkbox" @bind-value="includeDateTimeLocalSeconds" />Include seconds<br>
<InputDate
id="datetime-local-input"
Type="InputDateType.DateTimeLocal"
@bind-Value="person.AppointmentDateAndTime"
placeholder="Enter the appointment date and time"
step="@(includeDateTimeLocalSeconds ? 1 : 60)" />
</p>
<p class="ticket-class">
Ticket class:
Expand Down Expand Up @@ -145,6 +159,8 @@
EditContext editContext;
ValidationMessageStore customValidationMessageStore;
bool enableDataAnnotationsSupport = true;
bool includeTimeSeconds;
bool includeDateTimeLocalSeconds;

protected override void OnInitialized()
{
Expand Down