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 @@
{ checkboxInitiallyCheckedValue = !checkboxInitiallyCheckedValue; })">Invert
+Details element
+
+ Initially closed:
+
+ Click to expand
+ Details content
+
+ @detailsInitiallyClosedValue
+ { detailsInitiallyClosedValue = !detailsInitiallyClosedValue; })">Toggle
+
+
+ Initially open:
+
+ Click to collapse
+ Details content
+
+ @detailsInitiallyOpenValue
+ { detailsInitiallyOpenValue = !detailsInitiallyOpenValue; })">Toggle
+
+
Select
@@ -483,6 +503,9 @@
bool checkboxInitiallyUncheckedValue = false;
bool checkboxInitiallyCheckedValue = true;
+ bool detailsInitiallyClosedValue = false;
+ bool detailsInitiallyOpenValue = true;
+
int textboxIntValue = -42;
int? textboxNullableIntValue = null;
long textboxLongValue = 3_000_000_000;