Skip to content

Commit 67f3bef

Browse files
Fix keyboard input for 'time' and 'datetime-local' inputs (#35682)
1 parent dd78b44 commit 67f3bef

File tree

5 files changed

+142
-29
lines changed

5 files changed

+142
-29
lines changed

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Rendering/BrowserRenderer.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -390,20 +390,16 @@ export class BrowserRenderer {
390390
// Certain elements have built-in behaviour for their 'value' property
391391
const frameReader = batch.frameReader;
392392

393-
if (element.tagName === 'INPUT' && element.getAttribute('type') === 'time' && !element.getAttribute('step')) {
394-
const timeValue = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
395-
if (timeValue) {
396-
element['value'] = timeValue.substring(0, 5);
397-
return true;
398-
}
393+
let value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
394+
395+
if (value && element.tagName === 'INPUT') {
396+
value = normalizeInputValue(value, element.getAttribute('type'));
399397
}
400398

401399
switch (element.tagName) {
402400
case 'INPUT':
403401
case 'SELECT':
404402
case 'TEXTAREA': {
405-
let value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
406-
407403
// <select> is special, in that anything we write to .value will be lost if there
408404
// isn't yet a matching <option>. To maintain the expected behavior no matter the
409405
// element insertion/update order, preserve the desired value separately so
@@ -425,7 +421,6 @@ export class BrowserRenderer {
425421
return true;
426422
}
427423
case 'OPTION': {
428-
const value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
429424
if (value || value === '') {
430425
element.setAttribute('value', value);
431426
} else {
@@ -495,6 +490,27 @@ function parseMarkup(markup: string, isSvg: boolean) {
495490
}
496491
}
497492

493+
function normalizeInputValue(value: string, type: string | null): string {
494+
// Time inputs (e.g. 'time' and 'datetime-local') misbehave on chromium-based
495+
// browsers when a time is set that includes a seconds value of '00', most notably
496+
// when entered from keyboard input. This behavior is not limited to specific
497+
// 'step' attribute values, so we always remove the trailing seconds value if the
498+
// time ends in '00'.
499+
500+
switch (type) {
501+
case 'time':
502+
return value.length === 8 && value.endsWith('00')
503+
? value.substring(0, 5)
504+
: value;
505+
case 'datetime-local':
506+
return value.length === 19 && value.endsWith('00')
507+
? value.substring(0, 16)
508+
: value;
509+
default:
510+
return value;
511+
}
512+
}
513+
498514
function countDescendantFrames(batch: RenderBatch, frame: RenderTreeFrame): number {
499515
const frameReader = batch.frameReader;
500516
switch (frameReader.frameType(frame)) {

src/Components/test/E2ETest/Tests/FormsTest.cs

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,15 @@ public void InputDateInteractsWithEditContext_NullableDateTimeOffset()
255255
public void InputDateInteractsWithEditContext_TimeInput()
256256
{
257257
var appElement = MountTypicalValidationComponent();
258-
var departureTimeInput = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.TagName("input"));
259258
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
259+
var departureTimeInput = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-input"));
260+
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-seconds-checkbox"));
261+
262+
// Ensure we're not using a custom step
263+
if (includeSecondsCheckbox.Selected)
264+
{
265+
includeSecondsCheckbox.Click();
266+
}
260267

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

274-
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/35498")]
281+
[Fact]
282+
public void InputDateInteractsWithEditContext_TimeInput_Step()
283+
{
284+
var appElement = MountTypicalValidationComponent();
285+
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
286+
var departureTimeInput = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-input"));
287+
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("departure-time")).FindElement(By.Id("time-seconds-checkbox"));
288+
289+
// Ensure we're using a custom step
290+
if (!includeSecondsCheckbox.Selected)
291+
{
292+
includeSecondsCheckbox.Click();
293+
}
294+
295+
// Input works with seconds value of zero and has the expected final value
296+
Browser.Equal("valid", () => departureTimeInput.GetAttribute("class"));
297+
departureTimeInput.SendKeys("111111");
298+
Browser.Equal("modified valid", () => departureTimeInput.GetAttribute("class"));
299+
Browser.Equal("11:11:11", () => departureTimeInput.GetAttribute("value"));
300+
301+
// Input works with non-zero seconds value
302+
// Move to the beginning of the input and put the new time
303+
departureTimeInput.SendKeys(string.Concat(Enumerable.Repeat(Keys.ArrowLeft, 3)) + "101010");
304+
Browser.Equal("modified valid", () => departureTimeInput.GetAttribute("class"));
305+
Browser.Equal("10:10:10", () => departureTimeInput.GetAttribute("value"));
306+
307+
// Can become invalid
308+
departureTimeInput.SendKeys(Keys.Backspace);
309+
Browser.Equal("modified invalid", () => departureTimeInput.GetAttribute("class"));
310+
Browser.Equal(new[] { "The DepartureTime field must be a time." }, messagesAccessor);
311+
}
312+
313+
[Fact]
275314
public void InputDateInteractsWithEditContext_MonthInput()
276315
{
277316
var appElement = MountTypicalValidationComponent();
@@ -283,50 +322,92 @@ public void InputDateInteractsWithEditContext_MonthInput()
283322
visitMonthInput.SendKeys($"03{Keys.ArrowRight}2005\t");
284323
Browser.Equal("modified valid", () => visitMonthInput.GetAttribute("class"));
285324

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

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

335+
// Can become valid again
296336
visitMonthInput.Clear();
297-
visitMonthInput.SendKeys($"05{Keys.ArrowRight}2007\t");
337+
visitMonthInput.SendKeys($"11{Keys.ArrowRight}1111\t");
298338
Browser.Equal("modified valid", () => visitMonthInput.GetAttribute("class"));
299339
Browser.Empty(messagesAccessor);
300340
}
301341

302-
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/35498")]
342+
[Fact]
303343
[QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/34884")]
304344
public void InputDateInteractsWithEditContext_DateTimeLocalInput()
305345
{
306346
var appElement = MountTypicalValidationComponent();
307-
var appointmentInput = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.TagName("input"));
308347
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
348+
var appointmentInput = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-input"));
349+
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-seconds-checkbox"));
309350

310-
// Validates on edit
351+
// Ensure we're not using a custom step
352+
if (includeSecondsCheckbox.Selected)
353+
{
354+
includeSecondsCheckbox.Click();
355+
}
356+
357+
// Validates on edit and has the expected value
311358
Browser.Equal("valid", () => appointmentInput.GetAttribute("class"));
312-
appointmentInput.SendKeys("01\t02\t1988\t0523\t1");
359+
appointmentInput.SendKeys($"01011970{Keys.ArrowRight}05421");
313360
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
314361

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

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

325-
appointmentInput.SendKeys("01234567\t11551\t");
372+
// Can become valid again
373+
appointmentInput.Clear();
374+
appointmentInput.SendKeys($"11111111{Keys.ArrowRight}11111");
326375
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
327376
Browser.Empty(messagesAccessor);
328377
}
329378

379+
[Fact]
380+
public void InputDateInteractsWithEditContext_DateTimeLocalInput_Step()
381+
{
382+
var appElement = MountTypicalValidationComponent();
383+
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
384+
var appointmentInput = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-input"));
385+
var includeSecondsCheckbox = appElement.FindElement(By.ClassName("appointment-date-time")).FindElement(By.Id("datetime-local-seconds-checkbox"));
386+
387+
// Ensure we're using a custom step
388+
if (!includeSecondsCheckbox.Selected)
389+
{
390+
includeSecondsCheckbox.Click();
391+
}
392+
393+
// Input works with seconds value of zero and has the expected final value
394+
Browser.Equal("valid", () => appointmentInput.GetAttribute("class"));
395+
appointmentInput.SendKeys($"11111970{Keys.ArrowRight}114216");
396+
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
397+
Browser.Equal("1970-11-11T11:42:16", () => appointmentInput.GetAttribute("value"));
398+
399+
// Input works with non-zero seconds value
400+
// Move to the beginning of the input and put the new value
401+
appointmentInput.SendKeys(string.Concat(Enumerable.Repeat(Keys.ArrowLeft, 6)) + $"10101970{Keys.ArrowRight}105321");
402+
Browser.Equal("modified valid", () => appointmentInput.GetAttribute("class"));
403+
Browser.Equal("1970-10-10T10:53:21", () => appointmentInput.GetAttribute("value"));
404+
405+
// Can become invalid
406+
appointmentInput.SendKeys(Keys.Backspace);
407+
Browser.Equal("modified invalid", () => appointmentInput.GetAttribute("class"));
408+
Browser.Equal(new[] { "The AppointmentDateAndTime field must be a date and time." }, messagesAccessor);
409+
}
410+
330411
[Fact]
331412
public void InputSelectInteractsWithEditContext()
332413
{

src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,27 @@
3434
Expiry date (optional): <InputDate @bind-Value="person.OptionalExpiryDate" />
3535
</p>
3636
<p class="departure-time">
37-
Departure time: <InputDate Type="InputDateType.Time" @bind-Value="person.DepartureTime" placeholder="Enter the time" />
37+
Departure time:<br>
38+
<input id="time-seconds-checkbox" type="checkbox" @bind-value="includeTimeSeconds" />Include seconds<br>
39+
<InputDate
40+
id="time-input"
41+
Type="InputDateType.Time"
42+
@bind-Value="person.DepartureTime"
43+
placeholder="Enter the time"
44+
step="@(includeTimeSeconds ? 1 : 60)" />
3845
</p>
3946
<p class="visit-month">
4047
Month of visit: <InputDate Type="InputDateType.Month" @bind-Value="person.VisitMonth" placeholder="Enter the month of your visit" />
4148
</p>
4249
<p class="appointment-date-time">
43-
Appointment date and time: <InputDate Type="InputDateType.DateTimeLocal" @bind-Value="person.AppointmentDateAndTime" placeholder="Enter the appointment date and time" />
50+
Appointment date and time:<br>
51+
<input id="datetime-local-seconds-checkbox" type="checkbox" @bind-value="includeDateTimeLocalSeconds" />Include seconds<br>
52+
<InputDate
53+
id="datetime-local-input"
54+
Type="InputDateType.DateTimeLocal"
55+
@bind-Value="person.AppointmentDateAndTime"
56+
placeholder="Enter the appointment date and time"
57+
step="@(includeDateTimeLocalSeconds ? 1 : 60)" />
4458
</p>
4559
<p class="ticket-class">
4660
Ticket class:
@@ -145,6 +159,8 @@
145159
EditContext editContext;
146160
ValidationMessageStore customValidationMessageStore;
147161
bool enableDataAnnotationsSupport = true;
162+
bool includeTimeSeconds;
163+
bool includeDateTimeLocalSeconds;
148164

149165
protected override void OnInitialized()
150166
{

0 commit comments

Comments
 (0)