Skip to content

Commit e65cec1

Browse files
Support for 'multiple' attribute in '<select>' elements. (#33950)
1 parent 60b90db commit e65cec1

File tree

11 files changed

+370
-31
lines changed

11 files changed

+370
-31
lines changed

src/Components/Components.slnf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@
6969
"src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj",
7070
"src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj",
7171
"src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj",
72+
"src\\Identity\\ApiAuthorization.IdentityServer\\src\\Microsoft.AspNetCore.ApiAuthorization.IdentityServer.csproj",
73+
"src\\Identity\\EntityFrameworkCore\\src\\Microsoft.AspNetCore.Identity.EntityFrameworkCore.csproj",
74+
"src\\Identity\\UI\\src\\Microsoft.AspNetCore.Identity.UI.csproj",
7275
"src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj",
7376
"src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj",
7477
"src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj",
@@ -96,8 +99,10 @@
9699
"src\\Mvc\\Mvc\\src\\Microsoft.AspNetCore.Mvc.csproj",
97100
"src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj",
98101
"src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj",
102+
"src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj",
99103
"src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj",
100104
"src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj",
105+
"src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj",
101106
"src\\Servers\\Connections.Abstractions\\src\\Microsoft.AspNetCore.Connections.Abstractions.csproj",
102107
"src\\Servers\\IIS\\IISIntegration\\src\\Microsoft.AspNetCore.Server.IISIntegration.csproj",
103108
"src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj",

src/Components/Components/src/BindConverter.cs

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using System.Diagnostics.CodeAnalysis;
88
using System.Globalization;
99
using System.Reflection;
10+
using System.Text;
11+
using System.Text.Json;
1012

1113
namespace Microsoft.AspNetCore.Components
1214
{
@@ -523,7 +525,7 @@ public static bool TryConvertToString(object? obj, CultureInfo? culture, out str
523525
return ConvertToStringCore(obj, culture, out value);
524526
}
525527

526-
internal readonly static BindParser<string?> ConvertToString = ConvertToStringCore;
528+
internal static readonly BindParser<string?> ConvertToString = ConvertToStringCore;
527529

528530
private static bool ConvertToStringCore(object? obj, CultureInfo? culture, out string? value)
529531
{
@@ -556,8 +558,8 @@ public static bool TryConvertToNullableBool(object? obj, CultureInfo? culture, o
556558
return ConvertToNullableBoolCore(obj, culture, out value);
557559
}
558560

559-
internal readonly static BindParser<bool> ConvertToBool = ConvertToBoolCore;
560-
internal readonly static BindParser<bool?> ConvertToNullableBool = ConvertToNullableBoolCore;
561+
internal static readonly BindParser<bool> ConvertToBool = ConvertToBoolCore;
562+
internal static readonly BindParser<bool?> ConvertToNullableBool = ConvertToNullableBoolCore;
561563

562564
private static bool ConvertToBoolCore(object? obj, CultureInfo? culture, out bool value)
563565
{
@@ -1278,8 +1280,18 @@ private static bool ConvertToNullableEnum<T>(object? obj, CultureInfo? culture,
12781280

12791281
private static class FormatterDelegateCache
12801282
{
1281-
private readonly static ConcurrentDictionary<Type, Delegate> _cache = new ConcurrentDictionary<Type, Delegate>();
1283+
private static readonly ConcurrentDictionary<Type, Delegate> _cache = new ConcurrentDictionary<Type, Delegate>();
12821284

1285+
private static MethodInfo? _makeArrayFormatter;
1286+
1287+
[UnconditionalSuppressMessage(
1288+
"ReflectionAnalysis",
1289+
"IL2060:MakeGenericMethod",
1290+
Justification = "The referenced methods don't have any DynamicallyAccessedMembers annotations. See https://github.com/mono/linker/issues/1727")]
1291+
[UnconditionalSuppressMessage(
1292+
"ReflectionAnalysis",
1293+
"IL2075:MakeGenericMethod",
1294+
Justification = "The referenced methods don't have any DynamicallyAccessedMembers annotations. See https://github.com/mono/linker/issues/1727")]
12831295
public static BindFormatter<T> Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
12841296
{
12851297
if (!_cache.TryGetValue(typeof(T), out var formatter))
@@ -1366,6 +1378,12 @@ private static class FormatterDelegateCache
13661378
{
13671379
formatter = (BindFormatter<T>)FormatEnumValueCore<T>;
13681380
}
1381+
else if (typeof(T).IsArray)
1382+
{
1383+
var method = _makeArrayFormatter ??= typeof(FormatterDelegateCache).GetMethod(nameof(MakeArrayFormatter), BindingFlags.NonPublic | BindingFlags.Static)!;
1384+
var elementType = typeof(T).GetElementType()!;
1385+
formatter = (Delegate)method.MakeGenericMethod(elementType).Invoke(null, null)!;
1386+
}
13691387
else
13701388
{
13711389
formatter = MakeTypeConverterFormatter<T>();
@@ -1377,6 +1395,36 @@ private static class FormatterDelegateCache
13771395
return (BindFormatter<T>)formatter;
13781396
}
13791397

1398+
private static BindFormatter<T[]> MakeArrayFormatter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
1399+
{
1400+
var elementFormatter = Get<T>();
1401+
1402+
return FormatArrayValue;
1403+
1404+
string? FormatArrayValue(T[] value, CultureInfo? culture)
1405+
{
1406+
if (value.Length == 0)
1407+
{
1408+
return "[]";
1409+
}
1410+
1411+
var builder = new StringBuilder("[\"");
1412+
builder.Append(JsonEncodedText.Encode(elementFormatter(value[0], culture)?.ToString() ?? string.Empty));
1413+
builder.Append('\"');
1414+
1415+
for (var i = 1; i < value.Length; i++)
1416+
{
1417+
builder.Append(", \"");
1418+
builder.Append(JsonEncodedText.Encode(elementFormatter(value[i], culture)?.ToString() ?? string.Empty));
1419+
builder.Append('\"');
1420+
}
1421+
1422+
builder.Append(']');
1423+
1424+
return builder.ToString();
1425+
}
1426+
}
1427+
13801428
private static BindFormatter<T> MakeTypeConverterFormatter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
13811429
{
13821430
var typeConverter = TypeDescriptor.GetConverter(typeof(T));
@@ -1400,15 +1448,20 @@ string FormatWithTypeConverter(T value, CultureInfo? culture)
14001448

14011449
internal static class ParserDelegateCache
14021450
{
1403-
private readonly static ConcurrentDictionary<Type, Delegate> _cache = new ConcurrentDictionary<Type, Delegate>();
1451+
private static readonly ConcurrentDictionary<Type, Delegate> _cache = new ConcurrentDictionary<Type, Delegate>();
14041452

14051453
private static MethodInfo? _convertToEnum;
14061454
private static MethodInfo? _convertToNullableEnum;
1455+
private static MethodInfo? _makeArrayTypeConverter;
14071456

14081457
[UnconditionalSuppressMessage(
14091458
"ReflectionAnalysis",
14101459
"IL2060:MakeGenericMethod",
14111460
Justification = "The referenced methods don't have any DynamicallyAccessedMembers annotations. See https://github.com/mono/linker/issues/1727")]
1461+
[UnconditionalSuppressMessage(
1462+
"ReflectionAnalysis",
1463+
"IL2075:MakeGenericMethod",
1464+
Justification = "The referenced methods don't have any DynamicallyAccessedMembers annotations. See https://github.com/mono/linker/issues/1727")]
14121465
public static BindParser<T> Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
14131466
{
14141467
if (!_cache.TryGetValue(typeof(T), out var parser))
@@ -1503,6 +1556,12 @@ internal static class ParserDelegateCache
15031556
var method = _convertToNullableEnum ??= typeof(BindConverter).GetMethod(nameof(ConvertToNullableEnum), BindingFlags.NonPublic | BindingFlags.Static)!;
15041557
parser = method.MakeGenericMethod(innerType).CreateDelegate(typeof(BindParser<T>), target: null);
15051558
}
1559+
else if (typeof(T).IsArray)
1560+
{
1561+
var method = _makeArrayTypeConverter ??= typeof(ParserDelegateCache).GetMethod(nameof(MakeArrayTypeConverter), BindingFlags.NonPublic | BindingFlags.Static)!;
1562+
var elementType = typeof(T).GetElementType()!;
1563+
parser = (Delegate)method.MakeGenericMethod(elementType).Invoke(null, null)!;
1564+
}
15061565
else
15071566
{
15081567
parser = MakeTypeConverterConverter<T>();
@@ -1514,6 +1573,36 @@ internal static class ParserDelegateCache
15141573
return (BindParser<T>)parser;
15151574
}
15161575

1576+
private static BindParser<T[]?> MakeArrayTypeConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
1577+
{
1578+
var elementParser = Get<T>();
1579+
1580+
return ConvertToArray;
1581+
1582+
bool ConvertToArray(object? obj, CultureInfo? culture, out T[]? value)
1583+
{
1584+
if (obj is not Array initialArray)
1585+
{
1586+
value = default;
1587+
return false;
1588+
}
1589+
1590+
var convertedArray = new T[initialArray.Length];
1591+
1592+
for (var i = 0; i < initialArray.Length; i++)
1593+
{
1594+
if (!elementParser(initialArray.GetValue(i), culture, out convertedArray[i]!))
1595+
{
1596+
value = default;
1597+
return false;
1598+
}
1599+
}
1600+
1601+
value = convertedArray;
1602+
return true;
1603+
}
1604+
}
1605+
15171606
private static BindParser<T> MakeTypeConverterConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
15181607
{
15191608
var typeConverter = TypeDescriptor.GetConverter(typeof(T));

src/Components/Shared/src/WebEventData.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,11 +252,36 @@ private static ChangeEventArgs DeserializeChangeEventArgs(string eventArgsJson,
252252
case JsonValueKind.False:
253253
changeArgs.Value = jsonElement.GetBoolean();
254254
break;
255+
case JsonValueKind.Array:
256+
changeArgs.Value = GetJsonElementStringArrayValue(jsonElement);
257+
break;
255258
default:
256259
throw new ArgumentException($"Unsupported {nameof(ChangeEventArgs)} value {jsonElement}.");
257260
}
261+
258262
return changeArgs;
259263
}
264+
265+
private static string?[] GetJsonElementStringArrayValue(JsonElement jsonElement)
266+
{
267+
var result = new string?[jsonElement.GetArrayLength()];
268+
var elementIndex = 0;
269+
270+
foreach (var arrayElement in jsonElement.EnumerateArray())
271+
{
272+
if (arrayElement.ValueKind != JsonValueKind.String)
273+
{
274+
throw new InvalidOperationException(
275+
$"Unsupported {nameof(JsonElement)} value kind '{arrayElement.ValueKind}' " +
276+
$"(expected '{JsonValueKind.String}').");
277+
}
278+
279+
result[elementIndex] = arrayElement.GetString();
280+
elementIndex++;
281+
}
282+
283+
return result;
284+
}
260285
}
261286

262287
[JsonSerializable(typeof(WebEventDescriptor))]

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: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -267,19 +267,29 @@ export class BrowserRenderer {
267267
this.trySetSelectValueFromOptionElement(newDomElementRaw);
268268
} else if (deferredValuePropname in newDomElementRaw) {
269269
// Situation 2
270-
const deferredValue: string | null = newDomElementRaw[deferredValuePropname];
271-
setDeferredElementValue(newDomElementRaw, deferredValue);
270+
setDeferredElementValue(newDomElementRaw, newDomElementRaw[deferredValuePropname]);
272271
}
273272
}
274273

275274
private trySetSelectValueFromOptionElement(optionElement: HTMLOptionElement) {
276275
const selectElem = this.findClosestAncestorSelectElement(optionElement);
277-
if (selectElem && (deferredValuePropname in selectElem) && selectElem[deferredValuePropname] === optionElement.value) {
278-
setDeferredElementValue(selectElem, optionElement.value);
276+
277+
if (!selectElem || !(deferredValuePropname in selectElem)) {
278+
return false;
279+
}
280+
281+
if (isMultipleSelectElement(selectElem)) {
282+
optionElement.selected = selectElem[deferredValuePropname].indexOf(optionElement.value) !== -1;
283+
} else {
284+
if (selectElem[deferredValuePropname] !== optionElement.value) {
285+
return false;
286+
}
287+
288+
setSingleSelectElementValue(selectElem, optionElement.value);
279289
delete selectElem[deferredValuePropname];
280-
return true;
281290
}
282-
return false;
291+
292+
return true;
283293
}
284294

285295
private insertComponent(batch: RenderBatch, parent: LogicalElement, childIndex: number, frame: RenderTreeFrame) {
@@ -378,7 +388,7 @@ export class BrowserRenderer {
378388
case 'INPUT':
379389
case 'SELECT':
380390
case 'TEXTAREA': {
381-
const value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
391+
let value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
382392

383393
// <select> is special, in that anything we write to .value will be lost if there
384394
// isn't yet a matching <option>. To maintain the expected behavior no matter the
@@ -390,8 +400,13 @@ export class BrowserRenderer {
390400
// default attribute values that may incorrectly constain the specified 'value'.
391401
// For example, range inputs have default 'min' and 'max' attributes that may incorrectly
392402
// clamp the 'value' property if it is applied before custom 'min' and 'max' attributes.
393-
element[deferredValuePropname] = value;
403+
404+
if (value && element instanceof HTMLSelectElement && isMultipleSelectElement(element)) {
405+
value = JSON.parse(value);
406+
}
407+
394408
setDeferredElementValue(element, value);
409+
element[deferredValuePropname] = value;
395410

396411
return true;
397412
}
@@ -515,17 +530,36 @@ function stripOnPrefix(attributeName: string) {
515530
throw new Error(`Attribute should be an event name, but doesn't start with 'on'. Value: '${attributeName}'`);
516531
}
517532

518-
function setDeferredElementValue(element: Element, value: string | null) {
533+
function isMultipleSelectElement(element: HTMLSelectElement) {
534+
return element.type === 'select-multiple';
535+
}
536+
537+
function setSingleSelectElementValue(element: HTMLSelectElement, value: string | null) {
538+
// There's no sensible way to represent a select option with value 'null', because
539+
// (1) HTML attributes can't have null values - the closest equivalent is absence of the attribute
540+
// (2) When picking an <option> with no 'value' attribute, the browser treats the value as being the
541+
// *text content* on that <option> element. Trying to suppress that default behavior would involve
542+
// a long chain of special-case hacks, as well as being breaking vs 3.x.
543+
// So, the most plausible 'null' equivalent is an empty string. It's unfortunate that people can't
544+
// write <option value=@someNullVariable>, and that we can never distinguish between null and empty
545+
// string in a bound <select>, but that's a limit in the representational power of HTML.
546+
element.value = value || '';
547+
}
548+
549+
function setMultipleSelectElementValue(element: HTMLSelectElement, value: string[] | null) {
550+
value ||= [];
551+
for (let i = 0; i < element.options.length; i++) {
552+
element.options[i].selected = value.indexOf(element.options[i].value) !== -1;
553+
}
554+
}
555+
556+
function setDeferredElementValue(element: Element, value: any) {
519557
if (element instanceof HTMLSelectElement) {
520-
// There's no sensible way to represent a select option with value 'null', because
521-
// (1) HTML attributes can't have null values - the closest equivalent is absence of the attribute
522-
// (2) When picking an <option> with no 'value' attribute, the browser treats the value as being the
523-
// *text content* on that <option> element. Trying to suppress that default behavior would involve
524-
// a long chain of special-case hacks, as well as being breaking vs 3.x.
525-
// So, the most plausible 'null' equivalent is an empty string. It's unfortunate that people can't
526-
// write <option value=@someNullVariable>, and that we can never distinguish between null and empty
527-
// string in a bound <select>, but that's a limit in the representational power of HTML.
528-
element.value = value || '';
558+
if (isMultipleSelectElement(element)) {
559+
setMultipleSelectElementValue(element, value);
560+
} else {
561+
setSingleSelectElementValue(element, value);
562+
}
529563
} else {
530564
(element as any).value = value;
531565
}

src/Components/Web.JS/src/Rendering/Events/EventTypes.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ function parseChangeEvent(event: Event): ChangeEventArgs {
103103
if (isTimeBasedInput(element)) {
104104
const normalizedValue = normalizeTimeBasedValue(element);
105105
return { value: normalizedValue };
106+
} else if (isMultipleSelectInput(element)) {
107+
const selectElement = element as HTMLSelectElement;
108+
const selectedValues = Array.from(selectElement.options)
109+
.filter(option => option.selected)
110+
.map(option => option.value);
111+
return { value: selectedValues };
106112
} else {
107113
const targetIsCheckbox = isCheckbox(element);
108114
const newValue = targetIsCheckbox ? !!element['checked'] : element['value'];
@@ -243,6 +249,10 @@ function isTimeBasedInput(element: Element): element is HTMLInputElement {
243249
return timeBasedInputs.indexOf(element.getAttribute('type')!) !== -1;
244250
}
245251

252+
function isMultipleSelectInput(element: Element): element is HTMLSelectElement {
253+
return element instanceof HTMLSelectElement && element.type === 'select-multiple';
254+
}
255+
246256
function normalizeTimeBasedValue(element: HTMLInputElement): string {
247257
const value = element.value;
248258
const type = element.type;
@@ -264,7 +274,7 @@ function normalizeTimeBasedValue(element: HTMLInputElement): string {
264274
// The following interfaces must be kept in sync with the EventArgs C# classes
265275

266276
interface ChangeEventArgs {
267-
value: string | boolean;
277+
value: string | boolean | string[];
268278
}
269279

270280
interface DragEventArgs {

0 commit comments

Comments
 (0)