Skip to content

Commit

Permalink
New dot:ModalDialog control, wrapper for <dialog>
Browse files Browse the repository at this point in the history
The control closes or shows the modal based on an Open property.
The modal is always shown using the .showModal() method,
non-modal dialog is already accessible by binding the open
attribute of dialog HTML element.
The Open property may either be a boolean or a nullable object,
the dialog is shown if the value isn't false nor null.

On close event, false or null is written back into the Open property.
Otherwise, we'd quickly have inconsistent viewModel whenever
the user closes the dialog with ESC.
Close event is also provided for explicit event handling.

We also optionaly provide a helper for implementing
"close after backdrop click" functionality. It is not supported by
the dialog element natively and could not be otherwise
implemented without writing custom JS.
It is enabled by setting CloseOnBackdropClick=true

Resolves  #1708
  • Loading branch information
exyi committed Feb 26, 2024
1 parent 2304616 commit 259476a
Show file tree
Hide file tree
Showing 7 changed files with 359 additions and 1 deletion.
81 changes: 81 additions & 0 deletions src/Framework/Framework/Controls/ModalDialog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System;
using System.Net;
using System.Text;
using DotVVM.Framework.Binding;
using DotVVM.Framework.Binding.Expressions;
using DotVVM.Framework.Hosting;
using DotVVM.Framework.ResourceManagement;
using Newtonsoft.Json;

namespace DotVVM.Framework.Controls
{
/// <summary>
/// Renders a HTML native dialog element, it is opened using the showModal function when the <see cref="Open" /> property is set to true
/// </summary>
/// <remarks>
/// * Non-modal dialogs may be simply binding the attribute of the HTML dialog element
/// * The dialog may be closed by button with formmethod="dialog", when ESC is pressed, or when the backdrop is clicked if <see cref="CloseOnBackdropClick" /> = true
/// </remarks>
[ControlMarkupOptions()]
public class ModalDialog : HtmlGenericControl
{
public ModalDialog()
: base("dialog", false)
{
}

/// <summary> A value indicating whether the dialog is open. The value can either be a boolean or an object (not false or not null -> shown). On close, the value is written back into the Open binding. </summary>
[MarkupOptions(AllowHardCodedValue = false)]
public object Open
{
get { return (bool?)GetValue(OpenProperty) ?? false; }
set { SetValue(OpenProperty, value); }
}
public static readonly DotvvmProperty OpenProperty =
DotvvmProperty.Register<object, ModalDialog>(nameof(Open), false);

/// <summary> Add an event handler which closes the dialog when the backdrop is clicked. </summary>
public bool CloseOnBackdropClick
{
get { return (bool?)GetValue(CloseOnBackdropClickProperty) ?? false; }
set { SetValue(CloseOnBackdropClickProperty, value); }
}
public static readonly DotvvmProperty CloseOnBackdropClickProperty =
DotvvmProperty.Register<bool, ModalDialog>(nameof(CloseOnBackdropClick), false);

/// <summary> Triggered when the dialog is closed. Called regardless if it was closed by user input or by <see cref="Open"/> change. </summary>
public Command? Close
{
get { return (Command?)GetValue(CloseProperty); }
set { SetValue(CloseProperty, value); }
}
public static readonly DotvvmProperty CloseProperty =
DotvvmProperty.Register<Command, ModalDialog>(nameof(Close));

protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context)
{
var valueBinding = GetValueBinding(OpenProperty);
if (valueBinding is {})
{
writer.AddKnockoutDataBind("dotvvm-modal-open", this, valueBinding);
}
else if (!(Open is false or null))
{
// we have to use the binding handler instead of `open` attribute, because we need to call the showModal function
writer.AddKnockoutDataBind("dotvvm-modal-open", "true");
}

if (GetValueOrBinding<bool>(CloseOnBackdropClickProperty) is {} x && !x.ValueEquals(false))
{
writer.AddKnockoutDataBind("dotvvm-model-backdrop-close", x.GetJsExpression(this));
}

if (GetCommandBinding(CloseProperty) is {} close)
{
writer.AddAttribute("onclose", KnockoutHelper.GenerateClientPostBackScript(nameof(Close), close, this, returnValue: null));
}

base.AddAttributesToRender(writer, context);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import gridviewdataset from './gridviewdataset'
import namedCommand from './named-command'
import fileUpload from './file-upload'
import jsComponents from './js-component'
import modalDialog from './modal-dialog'

type KnockoutHandlerDictionary = {
[name: string]: KnockoutBindingHandler
Expand All @@ -26,7 +27,8 @@ const allHandlers: KnockoutHandlerDictionary = {
...gridviewdataset,
...namedCommand,
...fileUpload,
...jsComponents
...jsComponents,
...modalDialog
}

export default allHandlers
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export default {
"dotvvm-modal-open": {
init(element: HTMLDialogElement, valueAccessor: () => any) {
element.addEventListener("close", () => {
const value = valueAccessor();
if (ko.isWriteableObservable(value)) {
// if the value is object, set it to null
value(typeof value.peek() == "boolean" ? false : null)
}
})
},
update(element: HTMLDialogElement, valueAccessor: () => any) {
const value = ko.unwrap(valueAccessor()),
shouldOpen = value != null && value !== false;
if (shouldOpen != element.open) {
if (shouldOpen) {
element.showModal()
} else {
element.close()
}
}
},
},
"dotvvm-model-backdrop-close": {
init(element: HTMLDialogElement, valueAccessor: () => any) {
// closes the dialog when the backdrop is clicked
element.addEventListener("click", (e) => {
if (e.target == element) {
const elementRect = element.getBoundingClientRect(),
x = e.clientX,
y = e.clientY;
if (x < elementRect.left || x > elementRect.right || y < elementRect.top || y > elementRect.bottom) {
if (ko.unwrap(valueAccessor())) {
element.close();
}
}
}
})
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Net.Http.Headers;
using System.Text;
using DotVVM.Framework.ViewModel;

namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.ModalDialog
{
public class ModalDialogViewModel : DotvvmViewModelBase
{
public bool Dialog1Shown { get; set; }
public bool DialogChained1Shown { get; set; }
public bool DialogChained2Shown { get; set; }
public bool CloseEventDialogShown { get; set; }

public int? NullableIntController { get; set; }
public string NullableStringController { get; set; }

public DialogModel DialogWithModel { get; set; } = null;

public int CloseEventCounter { get; set; } = 0;

public void ShowDialogWithModel()
{
DialogWithModel = new DialogModel() { Property = "Hello" };
}

public void CloseDialogWithEvent()
{
CloseEventDialogShown = false;
}

public class DialogModel
{
public string Property { get; set; }
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.ModalDialog.ModalDialogViewModel

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title></title>

<style>
.button-active {
background-color: #4CAF50;
}
</style>
</head>
<body>
<h1>Modal dialogs</h1>

<p>
<dot:Button data-ui="btn-open-simple" Text="Simple dialog" Click="{staticCommand: Dialog1Shown = true}" Class-button-active={value: Dialog1Shown} />
<dot:Button data-ui="btn-open-chained1" Text="Chained dialog" Click="{staticCommand: DialogChained1Shown = true}" Class-button-active={value: DialogChained1Shown} />
<dot:Button data-ui="btn-open-close-event" Text="Dialog with clickable backdrop and close event" Click="{staticCommand: CloseEventDialogShown = true}" Class-button-active={value: CloseEventDialogShown} />
<dot:Button data-ui="btn-open-view-model" Text="Dialog with view model" Click="{command: ShowDialogWithModel()}" Class-button-active={value: DialogWithModel != null} />
<dot:Button data-ui="btn-open-int" Text="Dialog controlled by nullable number" Click="{command: NullableIntController = 0}" Class-button-active={value: NullableIntController != null} />
<dot:Button data-ui="btn-open-string" Text="Dialog controlled by nullable string" Click="{staticCommand: NullableStringController = ""}" Class-button-active={value: NullableStringController != null} />
</p>
<p>
Close events: <span data-ui="close-event-counter" InnerText={value: CloseEventCounter} />
</p>

<dot:ModalDialog Open={value: Dialog1Shown} data-ui=simple>
<form>
This is a simple modal dialog, close it by pressing ESC or clicking the <button data-ui=btn-close formmethod="dialog" type="submit">Form method=dialog</button> button.
</form>
</dot:ModalDialog>

<dot:ModalDialog Open={value: DialogChained1Shown} data-ui=chained1>
<p>This is the first chained modal dialog.</p>
<form>
<dot:Button data-ui=btn-next Text="Next" Click={staticCommand: DialogChained1Shown = false; DialogChained2Shown = true} />
<button data-ui=btn-close formmethod="dialog" type="submit">Cancel</button>
</form>
</dot:ModalDialog>

<dot:ModalDialog Open={value: DialogChained2Shown} data-ui=chained2>
<p>This is the second chained modal dialog.</p>
<dot:Button data-ui=btn-close Text="Close" Click={staticCommand: DialogChained2Shown = false} />
</dot:ModalDialog>

<dot:ModalDialog Open={value: CloseEventDialogShown} CloseOnBackdropClick Close={staticCommand: CloseEventCounter = CloseEventCounter + 1} data-ui=close-event>
Closing the dialog will increase the counter. Either
<ul>
<li>Click the backdrop</li>
<li>Press ESC</li>
<li><dot:Button data-ui=btn-close1 Click={staticCommand: CloseEventDialogShown=false}>Use staticCommand</dot:Button></li>
<li><dot:Button data-ui=btn-close2 Click={command: CloseDialogWithEvent()}>Use command</dot:Button></li>
<li> <form method="dialog"><button data-ui=btn-close3 type="submit">Form method=dialog</button></form></li>
</ul>
</dot:ModalDialog>

<dot:ModalDialog Open={value: DialogWithModel} data-ui=view-model>
<p>Edit this field: <dot:TextBox Text={value: DialogWithModel.Property} /> </p>
<p>
<dot:Button data-ui=btn-save Text="Save" Click={command: DialogWithModel = null} />
<form method="dialog"><button data-ui=btn-close type="submit">Cancel</button></form>
</p>
</dot:ModalDialog>

<dot:ModalDialog Open={value: NullableIntController} data-ui=int>
the number: <dot:TextBox data-ui=editor Text={value: NullableIntController} />
<form method="dialog"><button data-ui=btn-close type="submit">Close</button></form>
</dot:ModalDialog>

<dot:ModalDialog Open={value: NullableStringController} data-ui=string>
the string: <dot:TextBox data-ui=editor Text={value: NullableStringController} />
<form method="dialog"><button data-ui=btn-close type="submit">Close</button></form>
</dot:ModalDialog>
</body>
</html>

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions src/Samples/Tests/Tests/Feature/ModalDialogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System;
using System.Linq;
using DotVVM.Samples.Tests.Base;
using DotVVM.Testing.Abstractions;
using OpenQA.Selenium;
using OpenQA.Selenium.Interactions;
using Riganti.Selenium.Core;
using Riganti.Selenium.Core.Abstractions;
using Riganti.Selenium.Core.Api;
using Riganti.Selenium.DotVVM;
using Xunit;

namespace DotVVM.Samples.Tests.Feature
{
public class ModalDialogTests : AppSeleniumTest
{
public ModalDialogTests(Xunit.Abstractions.ITestOutputHelper output) : base(output)
{
}

IElementWrapper OpenDialog(IBrowserWrapper browser, string dialogId)
{
var button = browser.Single($"btn-open-{dialogId}", SelectByDataUi);
AssertUI.IsNotDisplayed(browser.Single(dialogId, SelectByDataUi));
button.Click();
AssertUI.HasClass(browser.Single($"btn-open-{dialogId}", SelectByDataUi), "button-active");
var dialog = browser.Single(dialogId, SelectByDataUi);
AssertUI.IsDisplayed(dialog);
return dialog;
}

void CheckDialogCloses(IBrowserWrapper browser, string id, Action<IElementWrapper> closeAction)
{
var dialog = OpenDialog(browser, id);
closeAction(dialog);
AssertUI.IsNotDisplayed(browser.Single(id, SelectByDataUi));
AssertUI.HasNotClass(browser.Single($"btn-open-{id}", SelectByDataUi), "button-active");
}

[Fact]
public void Feature_ModalDialog_Simple()
{
RunInAllBrowsers(browser => {
browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
CheckDialogCloses(browser, "simple", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
CheckDialogCloses(browser, "simple", dialog => {
// backdrop click does nothing
new Actions(browser.Driver).MoveToLocation(1, 1).Click().Perform();
AssertUI.IsDisplayed(dialog);

dialog.SendKeys(Keys.Escape);
AssertUI.IsNotDisplayed(dialog);
});

});
}

[Fact]
public void Feature_ModalDialog_Chained()
{
RunInAllBrowsers(browser => {
browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
var dialog1 = OpenDialog(browser, "chained1");
dialog1.Single("btn-next", SelectByDataUi).Click();
AssertUI.IsNotDisplayed(dialog1);
var dialog2 = browser.Single("chained2", SelectByDataUi);
AssertUI.IsDisplayed(dialog2);
dialog2.Single("btn-close", SelectByDataUi).Click();
AssertUI.IsNotDisplayed(dialog2);
});
}

[Fact]
public void Feature_ModalDialog_CloseEvent()
{
RunInAllBrowsers(browser => {
browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);

CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close1", SelectByDataUi).Click());
AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "1");
CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close2", SelectByDataUi).Click());
AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "2");
CheckDialogCloses(browser, "close-event", dialog => dialog.Single("btn-close3", SelectByDataUi).Click());
AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "3");
CheckDialogCloses(browser, "close-event", dialog => dialog.SendKeys(Keys.Escape));
AssertUI.InnerTextEquals(browser.Single("close-event-counter", SelectByDataUi), "4");

CheckDialogCloses(browser, "close-event", dialog => {
// dialog click
new Actions(browser.Driver).MoveToElement(dialog.WebElement, 1, 1).Click().Perform();
AssertUI.IsDisplayed(dialog);
// backdrop click
new Actions(browser.Driver).MoveToLocation(1, 1).Click().Perform();
});
});
}

[Fact]
public void Feature_ModalDialog_ModelController()
{
RunInAllBrowsers(browser => {
browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_ModalDialog_ModalDialog);
CheckDialogCloses(browser, "view-model", dialog => dialog.Single("btn-save", SelectByDataUi).Click());
CheckDialogCloses(browser, "view-model", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
CheckDialogCloses(browser, "int", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
// clearing the numeric input puts null into the nullable integer controller
CheckDialogCloses(browser, "int", dialog => dialog.Single("editor", SelectByDataUi).Clear());
CheckDialogCloses(browser, "string", dialog => dialog.Single("btn-close", SelectByDataUi).Click());
});
}
}
}

0 comments on commit 259476a

Please sign in to comment.