-
Notifications
You must be signed in to change notification settings - Fork 95
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New dot:ModalDialog control, wrapper for <dialog>
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
Showing
7 changed files
with
359 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
41 changes: 41 additions & 0 deletions
41
src/Framework/Framework/Resources/Scripts/binding-handlers/modal-dialog.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} | ||
}) | ||
} | ||
} | ||
} |
42 changes: 42 additions & 0 deletions
42
src/Samples/Common/ViewModels/FeatureSamples/ModalDialog/ModalDialogViewModel.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | ||
} | ||
} | ||
|
||
} |
79 changes: 79 additions & 0 deletions
79
src/Samples/Common/Views/FeatureSamples/ModalDialog/ModalDialog.dothtml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
}); | ||
} | ||
} | ||
} |