From 173a22bde7889e594b571fd84f41f9960756f137 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 4 Dec 2025 13:28:30 +0100 Subject: [PATCH] Add @bind-open support for details and dialog elements This change enables two-way binding on the 'open' attribute for
and elements using @bind-open syntax. Changes: - Added BindElement attributes for details element in BindAttributes.cs - Changed ontoggle event handler to use ChangeEventArgs instead of EventArgs - Updated WebEventData.cs to deserialize toggle events as ChangeEventArgs - Updated EventTypes.ts to use ToggleEvent.newState for reliable state detection - Added E2E tests for details element binding Fixes #64194 --- .../Web.JS/src/Rendering/Events/EventTypes.ts | 18 ++++++- src/Components/Web/src/Web/BindAttributes.cs | 4 ++ src/Components/Web/src/Web/EventHandlers.cs | 4 +- .../Web/src/WebEventData/WebEventData.cs | 6 ++- src/Components/test/E2ETest/Tests/BindTest.cs | 50 +++++++++++++++++++ .../BasicTestApp/BindCasesComponent.razor | 23 +++++++++ 6 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts b/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts index 89eb799ed3b9..ead24e840b90 100644 --- a/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts +++ b/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts @@ -160,7 +160,11 @@ registerBuiltInEventType(['wheel', 'mousewheel'], { createEventArgs: e => parseWheelEvent(e as WheelEvent), }); -registerBuiltInEventType(['cancel', 'close', 'toggle'], createBlankEventArgsOptions); +registerBuiltInEventType(['cancel', 'close'], createBlankEventArgsOptions); + +registerBuiltInEventType(['toggle'], { + createEventArgs: e => parseToggleEvent(e), +}); function parseChangeEvent(event: Event): ChangeEventArgs { const element = event.target as Element; @@ -190,6 +194,18 @@ function parseWheelEvent(event: WheelEvent): WheelEventArgs { }; } +function parseToggleEvent(event: Event): ChangeEventArgs { + // For
, , and popover elements, return a ChangeEventArgs + // compatible object with the open state as a boolean value. This enables @bind-open support. + // We use ToggleEvent.newState which works for all element types that fire toggle events. + const toggleEvent = event as ToggleEvent; + const isOpen = toggleEvent.newState === 'open'; + + return { + value: isOpen, + }; +} + function parsePointerEvent(event: PointerEvent): PointerEventArgs { return { ...parseMouseEvent(event), diff --git a/src/Components/Web/src/Web/BindAttributes.cs b/src/Components/Web/src/Web/BindAttributes.cs index 890e45b2a123..d1da7718244a 100644 --- a/src/Components/Web/src/Web/BindAttributes.cs +++ b/src/Components/Web/src/Web/BindAttributes.cs @@ -45,6 +45,10 @@ namespace Microsoft.AspNetCore.Components.Web; [BindElement("select", null, "value", "onchange")] [BindElement("textarea", null, "value", "onchange")] + +//
element binds to the 'open' attribute using the 'ontoggle' event +[BindElement("details", null, "open", "ontoggle")] +[BindElement("details", "open", "open", "ontoggle")] public static class BindAttributes { } diff --git a/src/Components/Web/src/Web/EventHandlers.cs b/src/Components/Web/src/Web/EventHandlers.cs index 8c7562f3999a..d04927519423 100644 --- a/src/Components/Web/src/Web/EventHandlers.cs +++ b/src/Components/Web/src/Web/EventHandlers.cs @@ -122,8 +122,8 @@ namespace Microsoft.AspNetCore.Components.Web; [EventHandler("onreadystatechange", typeof(EventArgs), true, true)] [EventHandler("onscroll", typeof(EventArgs), true, true)] -//
-[EventHandler("ontoggle", typeof(EventArgs), true, true)] +//
and elements with popover attribute +[EventHandler("ontoggle", typeof(ChangeEventArgs), true, true)] // [EventHandler("oncancel", typeof(EventArgs), false, true)] diff --git a/src/Components/Web/src/WebEventData/WebEventData.cs b/src/Components/Web/src/WebEventData/WebEventData.cs index 0e753a3b74c9..fcb72ca4ca56 100644 --- a/src/Components/Web/src/WebEventData/WebEventData.cs +++ b/src/Components/Web/src/WebEventData/WebEventData.cs @@ -187,10 +187,14 @@ private static bool TryDeserializeStandardWebEventArgs( case "cancel": case "close": - case "toggle": eventArgs = EventArgs.Empty; return true; + case "toggle": + // Toggle events return ChangeEventArgs with value=boolean to support @bind-open + eventArgs = ChangeEventArgsReader.Read(eventArgsJson); + return true; + default: // For custom event types, there are no built-in rules, so the deserialization type is // determined by the parameter declared on the delegate. diff --git a/src/Components/test/E2ETest/Tests/BindTest.cs b/src/Components/test/E2ETest/Tests/BindTest.cs index f98a01a8ce35..7617e61890ac 100644 --- a/src/Components/test/E2ETest/Tests/BindTest.cs +++ b/src/Components/test/E2ETest/Tests/BindTest.cs @@ -194,6 +194,56 @@ public void CanBindCheckbox_InitiallyChecked() Browser.Equal("True", () => boundValue.Text); } + [Fact] + public void CanBindDetailsElement_InitiallyClosed() + { + var target = Browser.Exists(By.Id("details-initially-closed")); + var boundValue = Browser.Exists(By.Id("details-initially-closed-value")); + var toggleButton = Browser.Exists(By.Id("details-initially-closed-toggle")); + Assert.Null(target.GetDomAttribute("open")); + Assert.Equal("False", boundValue.Text); + + // Click to expand; verify value is updated + target.FindElement(By.TagName("summary")).Click(); + Browser.NotEqual(null, () => target.GetDomAttribute("open")); + Browser.Equal("True", () => boundValue.Text); + + // Click to collapse; verify value is updated + target.FindElement(By.TagName("summary")).Click(); + Browser.Equal(null, () => target.GetDomAttribute("open")); + Browser.Equal("False", () => boundValue.Text); + + // Modify data programmatically; verify details is updated + toggleButton.Click(); + Browser.NotEqual(null, () => target.GetDomAttribute("open")); + Browser.Equal("True", () => boundValue.Text); + } + + [Fact] + public void CanBindDetailsElement_InitiallyOpen() + { + var target = Browser.Exists(By.Id("details-initially-open")); + var boundValue = Browser.Exists(By.Id("details-initially-open-value")); + var toggleButton = Browser.Exists(By.Id("details-initially-open-toggle")); + Assert.NotNull(target.GetDomAttribute("open")); + Assert.Equal("True", boundValue.Text); + + // Click to collapse; verify value is updated + target.FindElement(By.TagName("summary")).Click(); + Browser.Equal(null, () => target.GetDomAttribute("open")); + Browser.Equal("False", () => boundValue.Text); + + // Click to expand; verify value is updated + target.FindElement(By.TagName("summary")).Click(); + Browser.NotEqual(null, () => target.GetDomAttribute("open")); + Browser.Equal("True", () => boundValue.Text); + + // Modify data programmatically; verify details is updated + toggleButton.Click(); + Browser.Equal(null, () => target.GetDomAttribute("open")); + Browser.Equal("False", () => boundValue.Text); + } + [Fact] public void CanBindSelect() { diff --git a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor index 56bc049ebcb4..6b199f8853ef 100644 --- a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor @@ -302,6 +302,26 @@

+

Details element

+

+ Initially closed: +

+ Click to expand +

Details content

+
+ @detailsInitiallyClosedValue + +

+

+ Initially open: +

+ Click to collapse +

Details content

+
+ @detailsInitiallyOpenValue + +

+

Select