Click here to Skip to main content
14,207,627 members
Click here to Skip to main content
Add your own
alternative version

Stats

127.6K views
5.1K downloads
68 bookmarked
Posted 18 May 2015
Licenced CPOL

An Advanced and Easy-use AngularJS Modal Dialog

, 28 Mar 2017
Rate this:
Please Sign up or sign in to vote.
Updated the modal dialog compatible with Angular 1.5x and TypeScript. Also providing an example to open a dialog in the component controller class.

 

Update Notice: The updated NgExDialog has been added here to work on web applications that use the Angular 1.5x components and TypeScript. Please see more descriptions in the new section inserted to the end of the article. For developers who are interested in the Angular 2 version with the same functionality and features, please see the article and sample code for the Ng2ExDialog.

Introduction

I previously created a full-featured JQuery dialog plugin, the jqsDialog, for building web pages. Lately I needed the same kind of the modal dialog when developing website applications in AngularJS. Although many ready-use AngularJS modal dialog tools are available from the developer’s communities and other sources, none could be found as with the advanced features as the jqsDialog. I thus again created my own AngularJS modal dialog library, named as NgExDialog, to match all features delivered by the jqsDialog except for the non-modal option, which has very little practical significance, and the progress bar, as most website applications use an AJAX loader display instead.

The NgExDialog has these features:

  • Easy to use with standardized and simplified calling code.
  • Flexible and customizable for both common messaging and data display purposes.
  • Dialogs can be opened to any level deep and closed or kept open individually, closed with immediate parent together, or closed for all.
  • Configurable for all options, such as draggable, animation, icon, gray-background, close-by-click-outside, cancel confirmation, etc.
  • Themes and styles can be set for each component, such as main dialog, header, title, icon, message body, message text, footer, and buttons.
  • Distribution with single JavaScript file and linked CSS file, plus two image files if the icon display is enabled.
  • The dialog has only dependency on the angular.js and bootstrap.css. No other library or file reference is needed.

Based on these outstanding features, the internal code of the NgExDialog is somewhat complex. This article will not attempt to discuss the coding details inside the ngExDialog.js and ngExDialog.css files, but rather focus on how to use the tool in web applications and also address some major issues with solutions related to the use cases. If any audience is interested in NgExDialog internal code and structures, feel free to look into the details from the downloaded source. There are always comments for any code block or line if it’s not self-explanatory.

Dialog Access Scinarios and Syntax

The NgExDialog is built as an AngularJS service provider. In the sample, the code for opening dialogs are all in the controller.js and no top-level directive is available. Opening a dialog with a directive from AngularJS views is less practical than the code from AngularJS controllers in the real world since the dialog acts as a conditional and dynamic pop-up along a course of the application workflow.

Opening dialogs can follow these scenarios (with the NgExDialog already injected into the application module and the exDialog as called provider name):

  • Using a simple line of parameters for a message or confirm dialog if only default settings are needed or only the body text, title, or icon are specified.

    Syntax:

    [custom-object] = exDialog.openMessage($scope, "message-body", ["title"], ["icon"]);
    [premise-object] = exDialog.openConfirm($scope, "message-body", ["title"], ["icon"]);
  • Using the parameter object with needed properties for a message or confirm dialog if requiring any non-default setting other than the body text, tile, or icon.

    Syntax:

    [custom-object] = exDialog.openMessage($scope, parameter-object);
    [premise-object] = exDialog.openConfirm($scope, parameter-object);
  • Always passing the parameter object with needed properties for any custom or data loading dialog.

    Syntax:

    [custom-object] = exDialog.openPrime($scope, parameter-object);

The parameter object accepts these properties as listed below. The same can be found above the openMessage() method in the ngExDialog.js file.

@params {Object}:
//These for message and confirm types with built-in template only.
- title {String} - dialog title in header, default to "information" or configured
- icon {String} - values are 'info', 'warning', 'question', 'error', default to "info" or configured
- message {String} - body message
- closeButtonLabel {String} - close button label, default to "OK" for alert and "No" for confirm, or configured
- actionButtonLabel {String} - Action button label, default to "Yes" for confirm, or configured
- closeAllDialogs {Boolean} - close all dialogs including parents
- keepOpenForAction {Boolean} - keep previous confirmation dialog open when clicking action button, default to undefined, or configured
- keepOpenForClose {Boolean} - keep previous confirmation dialog open when clicking close button, default to undefined, or configured
- dialogAddClass {String}
- headerAddClass  {String}
- titleAddClass {String}
- bodyAddClass {String}
- messageAddClass {String}
- footerAddClass {String}
- actionButtonAddClass {String}
- closeButtonAddClass {String}

//These for all types including custom template.
- scope {Object} - source scope
- template {String} - id for ng-template script, url for file, or plain string containing HTML text. Required for openPrime()
- controller {String} - required if the custom template needs it.
- width (String} - dialog width, configured
- closeByXButton {Boolean} - show x close button, default true, or configured
- closeByEscKey {Boolean} - default true, or configured
- closeByClickOutside {Boolean} - default true, or configured
- beforeCloseCallback {String|Function} - user supplied function name/function called before closing dialog (if set)
- grayBackground {Boolean} - default true, or configured
- cacheTemplate {Boolean} - default true, or configured 
- draggable {Boolean} - default true, or configured
- animation {Boolean} - default true, or configured

Basic Use Case Examples

For sample demonstrations, you can downloaded source and add folders and files into any website project as long as it supports the HTML and JavaScript. All features of the ngExdialog should work well with the latest versions of Internet Explorer, Google Chrome, and Firefox. It's not guaranteed that other browser types and versions can do all the same.

Running the index.html will start the page showing the links for opening dialogs. You can even test any other cases with your own code to call the NgExDialog service.

  • Opening an information message dialog with required body text only:

    exDialog.openMessage($scope, "This is called from a simple line of parameters.");

  • Opening a warning message dialog with required message text only:

    exDialog.openMessage($scope, "This is called from a simple line of parameters.", "Warning", "warning");    

  • Opening a confirmation dialog with required body text only:

    exDialog.openConfirm($scope, "Would you like to close the dialog and open another one?").then(function (value) {
        exDialog.openMessage($scope, "This is another dialog.");
    }); 

  • Opening a message dialog with the animation and draggable disabled (Note that the animation and draggable features are enabled for all dialogs by default):

    exDialog.openMessage({
        scope: $scope,
        message: "Animation and drag-move disabled.",
        animation: false,
        draggable: false
    });

    The dialog displayed with all default title, icon, and button but there is no animation and draggable effects. You can see the result by clicking the Dialog without Animation and Dragging link on the demo page.

  • Opening a custom data form dialog:

    exDialog.openPrime({
       scope: $scope,
       template: 'Pages/_Product.html',
       controller: 'productController',
       width: '450px'
    });

    In this case, the NgExDialog provides the main frame features of the dialog. All contents and page-dialog communication processes are defined in the specified template and controller, including the action and close buttons, and all content styles. This will be more flexible for developers to design and implement the data form and its operations.

Dialog Display Templates

The built-in template in the NgExDialog for message and confirmation types of dialogs usually meets the needs of most common uses. The themes and styles can even be customized at the single component level. In case you need to modify it or add new components into it, you can make changes in the included commonDialog_0.html file and then use it as default template by switching the configuration item in the app.js file.

angular.module('smApp', ['smApp.controllers', 'smApp.AppServices', 'ngExDialog', function () {
}])
//Dialog default settings.
.config(['exDialogProvider', function (exDialogProvider) {
    exDialogProvider.setDefaults({        
        //template: 'ngExDialog/commonDialog.html', //from cache
        template: 'ngExDialog/commonDialog_0.html', //from file
        . . .
    });
}]);

You can also make changes in the commonDialogController inside the ngExDialog.js file if any added component needs code supports from its controller.

A particular custom template and its controller should be created for any types other than the common message or confirmation dialogs, such as the data form dialog (see the _Product.html template and productController in the controller.js from the sample source). In this scenario, the dialog uses the core features of the NgExDialog to interact with the environment. The custom template is responsible for the content of the visible dialog area. Thus, any data process, communication between the template and its controller, and look-and-feel of the dialog will be handled by your own code.

There are several forms of templates you can choose:

  1. id attribute name in the ng-template script code:

    <script type="text/ng-template" id="customDialogTemplate">
        <!--HTML code here-->
    	. . .		
    </script>
  2. URL path of the template HTML file, such as "/Pages/_Product.html".

  3. HTML text as a pure string beginning with the open tag symbol "<".

The template loader will parse the input values and automatically select the correct template form and content. No other indicator or flag is needed. The template will be cached before use if no cache for the same template exists unless you change this default behavior by setting the cacheTemplate input parameter object property to false.

Closing or Keeping Open Dialogs

There is a major structural difference between the NgExDialog and jqsDialog when used as multi-level common message and confirmation dialogs. The jqsDialog mostly re-uses an existing object instance and dynamically change the content of the object, such as body text, title, icon, and/or buttons, for a child dialog. The NgExDialog, however, always uses a new object instance to open a child dialog. By default, it firstly closes the parent dialog and then opens its child. Unlike most other AngularJs modal dialog tools, the NgExDialog provides options to keep any level of parent dialogs open on the background when a child dialog is shown. There are at least these benefits when enabling this feature:

  • Some dependent processes need co-existence of both parent and child dialogs, even for non-data-access dialogs.
  • When needed, users can see all dialogs loaded for the workflow.
  • The shuffling and flicking visual effects due to dialog transitions can be avoided.

The option can be enabled using the input parameter object properties for the dialog that will be kept open:

exDialog.openConfirm({
    scope: $scope,
    . . .,
    keepOpenForAction: true,
    keepOpenForClose: true
});

For a confirmation type dialog, the keepOpenForAction is for keeping the dialog open when clicking the action button, such as Yes, OK, Go, or Continue, and the keepOpenForClose is for clicking the close button, such as No or Cancel. For a message type dialog with only one OK, Go, or Continue button, only keepOpenForClose is available.

In most situations, commands of also closing immediate parent or closing all dialogs are needed from a child dialog when using the options to keep parent dialogs open.

If also closing the immediate parent from the code for a child dialog:

exDialog.openMessage({
    scope: $scope,
    . . .,
    closeImmediateParent: true
});

If closing all dialogs:

exDialog.openMessage({
    scope: $scope,
    . . .,
    closeAllDialogs: true
});

The existing parent dialog is always behind the newly opened child dialog. The parent dialog may not be seen at all if its size is smaller than the overlapped child dialog. Since the NgExDialog has the draggable feature (described later), the child dialog can be moved aside to view the underlying parent dialog.

Running Tasks When Closing Dialogs

For a dialog, commands to run tasks are usually initiated from the action button. The application workflow may sometimes need to run additional tasks when closing a dialog, such as a cancel warning, further confirmation, or redirecting to other pages. Three options are available for running tasks when a dialog is closed.

  1. Using custom callback function for any base screen of common message or confirmation dialog. You can specify a callback function for the input parameter object property beforeCloseCallback:

    exDialog.openConfirm({
        scope: $scope,
        actionButtonLabel: "Continue",
        closeButtonLabel: "Cancel",
        message: "What next step would you like to take?",
        beforeCloseCallback: function (value) {
            var rtnPremise = exDialog.openConfirm({
                scope: $scope,
                message: "Do you really want to cancel it?"
            });
            return rtnPremise;
        }    
    });

    With responding to the cancel confirmation, the workflow will be cancelled and all pop-up screens are closed when clicking the Yes button or it will return to the previous base screen that keeps everything as before when clicking the No button.

  2. Using the close premise object for the base screen of a confirmation dialog.

    exDialog.openConfirm($scope, "Would you like to open a second dialog?").then(function (value) {
        exDialog.openMessage($scope, "This is the second dialog.");
    }, function (reason) {
        exDialog.openMessage($scope, "The dialog has been closed.");
    });

    By default, the task running to the response of closing the dialog occurs after the dialog has been closed. Thus, this scenario is best used for a workflow that is not returned back to the base dialog screen. The below screenshot shows the transition moment of closing the cancel confirmation dialog and opening the final notification message dialog:

  3. Opening a confirmation dialog directly from the close button event for any base dialog with a custom template. This approach is pretty straightforward since the close button and its attributes are specified within the custom template. Here is the code for cancel warning and confirmation in the data form dialog example.

    exDialog.openConfirm({
        scope: $scope,
        title: "Cancel Warning",
        icon: "warning",
        message: "Do you really want to cancel the data editing?"    
    }).then(function (value) {                
        exDialog.openMessage({
            scope: $scope,
            title: "Notification",
            message: "The editing has been cancelled."        
        });
    }, function (reason) {
        exDialog.openMessage({
            scope: $scope,
            title: "Notification",
            message: "The editing will continue."        
        });
    });

    The screenshot shows the result when clicking No button on the Cance Warning dialog:

Draggable Dialogs

A draggable dialog allows user to watch any part of the underlying page content and hence is a user-friendly add-on. The NgExDialog is fully draggable and well adaptable to the screen resizing. The draggable option is set by default. You can turn off this feature at the application configuration level or disable it for any individual dialog as the example shown before.

Some particular comments are worth mentioning for using the draggable feature.

  1. When enabling the draggable for any dialog with a custom template containing input type elements, you need to specify additional ng-focus and ng-blur attributes for each input element as in the _Product.html example like this:

    <input type="text" class="form-control" data-ng-model="model.Product.ProductName" ng-focus="setDrag(true)" ng-blur="setDrag(false)" />

    This is because the draggable directive in the NgExDialog doesn’t call the preventDefault() function of the mousedown event which, if called, disables the input fields on the dialog. But with HTML default setting, when trying to highlight the text in the input field with the mouse, the entire dialog will be moving causing the normal text highlight functionality to fail. Thus, a flag is set inside the NgExDialog that receives boolean values from those input fields to disable and re-enable the dragging action when the mouse point is on and off any input field, respectively. 

    On below screenshot, the dialog cannot be dragged and moved when the input field is getting focused:

  2. When dragged and moved, the NgExDialog also disables possible selection of display text on the dialog and underlying page. Occasionally, selections of the display text may still occur especially if the dialog is moved to, or out of, window edges. This is due mainly to the browser compatibility issue or the browser doesn't fully support this line of JavaScript code used inside the draggable directive:

    window.getSelection().removeAllRanges();
  3. Resizing the window will always re-center the dialog within the window if the dialog has not been dragged since it opens. If the dialog is dragged and then the window is resized, the horizontal position of the dialog will be re-adjusted normally. The vertical location of the dialog, however, will be fixed on the points where the previous dragging ends. It’s not a bug. Such a behavior is intentionally implemented as a workaround to resolve the issue related to the vertical centering of the dialog. If resizing the window vertically to make the window's height smaller, it could result in part or all of the dialog out of the window at the dialog fixed position. Users can re-adjust the dialog positon before resizing the window again for this case.

Customizing Styles for Built-in Template

Additional CSS classes can be specified for components of the common message or confirmation dialog with the build-in template. For example, the dialog needs a single line border when displayed on the screen without the gray background. We can then add the dialogAddClass property into the input parameter object and specify the border-to-dialog CSS class in the dialog level.

exDialog.openMessage({
    scope: $scope,
    title: "No Icon, No Gray",
    icon: "none",
    message: "This is called by passing a parameter object",
    grayBackground: false,
    dialogAddClass: 'border-to-dialog'
});

It’s also very easy to make changes in header and footer styles for a particular dialog by adding the properties, headerAddClass and footerAddClass, to the parameter object and then creating corresponding CSS classes:

exDialog.openMessage({
    scope: $scope,
    title: "Look Different",
    icon: "none",
    message: "Show header and footer in other styles.",            
    headerAddClass: 'my-dialog-header',
    footerAddClass: 'my-dialog-footer'
});

The full list of available input parameter object properties for adding dialog component CSS classes are described in the beginning section. Any or all of these properties can also be set as default values from the application level configurations if it’s required to make all dialogs the same look-and-feel across the entire application.

Closing Dialogs with Browser Navigations

On the AngularJS page, any browser redirection to other site will automatically close any open dialog. However, there are some issues related to the browser's back and forward buttons.

Issue #1: Browser back button is enabled when opening a dialog on the page having no history activity. This may be caused by loading the dialog template html that has the location URL. This false button-enabling behavior should be avoided although clicking the back or forward button doesn't do anything. The resolution is simply to inject the AngularJS $location into the controllers that use the dialogs without adding any other code.

//Inject $location to controller that uses dialog to remove unwanted behaviors for browser navigation buttons.
.controller('sampleController', ['$scope', '$timeout', 'exDialog', '$location', function ($scope, $timeout, exDialog, $location) {
    //. . .
}]) 

Issue #2: Browsing back and forward on a page having any history activity and open dialog. This could keep the current modal dialog still shown over the background after switching to the previously visited page. Below approaches are used to resolve the issue.

  1. Adding a function, hasOpenDialog() to return a boolean flag for any open dialog in the current scope.
    hasOpenDialog: function () {                    
        if (document.querySelector('.dialog-main')) {
            return true;
        }
        else {
            return false;
        }
    }
  2. In the HTML body controller, the top level controller in the AngularJS SPA application, placing the code in the $locationChangeStart event handler. It will close any open dialog when the routing location changes and calling hasOpenDialog() returns true.
    .controller('bodyController', ['$scope', 'exDialog', '$location', function ($scope, exDialog, $location) {
        //Close dialog if any when clicking broswer navigation buttons.
        $scope.$on('$locationChangeStart', function (event, newUrl, oldUrl) {
            if (newUrl != oldUrl && exDialog.hasOpenDialog()) {
                exDialog.closeAll();
            }
        });
    }])    

The below screenshots illustrate the browser-back operations.

When browsing to the second page and opening a dialog on that page:

Clicking the browser back button:

The dialog is automatically closed and the process returns to the first main page:

Angular 1.5x TypeScript Compatible Updates

Angular 1.x has been considered as obsolete JavaScript framework since Angular 2 becomes stable. However, many existing web applications using the Angular 1.x still need tool or library supports. I would like to make the NgExDialog compatible to the Angular 1.3x through 1.5x and TypeScript, and also callable from the Angular 1.5x component code with minimal changes, rather than upgrading the NgExDialog to full TypeScript and component structures. The changes are outlined as follows:

  • Converted the Angular JS to TypeScript code for the NgExDialog and created the ngExDialog.ts file which works for TypeScript versions 1.8.3 to 2.1.5.
  • Edited the code for working with the Angular 1.3x to 1.5x. The downloaded source includes the version 1.5.8.
  • Checked that all of the functionality and features were not impacted.

The demo sample application works using either Visual Studio 2015 or 2017. The code on the Second Sample page has been re-written to open a dialog from Angular 1.5x component controller class.

The component node is specified in the secondSample.html:

<div class="container">
    <sample-second></sample-second>
</div>

The view content comes to the _sampleSecondTemplate.html:

<div >
    <h4 class="panel-indent">Test Browser Navigation Bottons</h4>        
    <a class="hy-link cursor-pointer" ng-click="vm.openSimpleInfo()">Open Information Dialog</a> 
</div>

All TypeScript classes and interface are in the secondSampleComponent.ts. Here are main code lines of the SecondSampleController for opening a message dialog (you can see all other details in that file):

class SecondSampleController implements ISecondSampleController {
    //Inject dependencies.
	static $inject = ['exDialog', '$scope']

    constructor(private exDialog, private $scope) { }

    openSimpleInfo() {
        this.exDialog.openMessage(this.$scope, "Open a dialog on second page.");
    }
}

Summary

The AngularJS modal dialog, NgExDialog, presented here is rich of functionality and yet very easy to use. I'm happy to share the tool and sample demo code with the developer's communities. Hope that web developers would like the tool and code. Any feedback will be welcome.

History

  • May 18, 2015: original post.
  • Aug 24, 2015: Added the resolution for closing dialogs when clicking the browser back or forward button. See the Closing Dialogs with Browser Navigations section for details. Source code files have also been updated.
  • Mar 28, 2017 Updated NgExDialog compatible to Angular 1.5x and TypeScript. A use example is provided in the component structure in the Second Sample page. The sample code running with Visual Studio 2015 or 2017 is added to the downloading list. 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Shenwei Liu
United States United States
Shenwei is a software developer and architect, and has been working on business applications using Microsoft and Oracle technologies since 1996. He obtained Microsoft Certified Systems Engineer (MCSE) in 1998 and Microsoft Certified Solution Developer (MCSD) in 1999. He has experience in ASP.NET, C#, Visual Basic, Windows and Web Services, Silverlight, WPF, JavaScript/AJAX, HTML, SQL Server, and Oracle.

Comments and Discussions

 
Praise:) Pin
Member 1124779614-Jun-17 9:08
memberMember 1124779614-Jun-17 9:08 
QuestionPage scrolls up on dialog open Pin
Dragan A.29-Apr-17 2:01
memberDragan A.29-Apr-17 2:01 
AnswerRe: Page scrolls up on dialog open Pin
Shenwei Liu10-May-17 17:34
memberShenwei Liu10-May-17 17:34 
QuestionPlease provide example as Visual Studio project Pin
MisterT9916-Jan-17 3:37
memberMisterT9916-Jan-17 3:37 
QuestionHow to send data from Modal Dialog to Parent Window Pin
Surya Kiran Bonugu21-Oct-16 1:27
professionalSurya Kiran Bonugu21-Oct-16 1:27 
AnswerRe: How to send data from Modal Dialog to Parent Window Pin
Shenwei Liu4-Nov-16 19:37
memberShenwei Liu4-Nov-16 19:37 
Use the prototype inheritance. You can define an object as a child of parent $scope. The object can be accessed by both parent and its dialog. For example in the parent controller:
$scope.product = {};
In the dialog controller:
$scope.product.name = "foo";
This product's name can directly be accessed from the parent controller, such as
var productName = $scope.product.name;

QuestionThere are two minor mistakes which makes it not "min safe".. Pin
asmodeus28-Mar-16 12:45
memberasmodeus28-Mar-16 12:45 
QuestionExcellent Pin
tuabin21-Jan-16 5:34
membertuabin21-Jan-16 5:34 
AnswerRe: Excellent Pin
Shenwei Liu24-Jan-16 17:01
memberShenwei Liu24-Jan-16 17:01 
QuestionFew questions... Pin
Rahman Mahmoodi23-Nov-15 0:18
memberRahman Mahmoodi23-Nov-15 0:18 
AnswerRe: Few questions... Pin
Shenwei Liu23-Nov-15 10:37
memberShenwei Liu23-Nov-15 10:37 
GeneralRe: Few questions... Pin
Rahman Mahmoodi23-Nov-15 14:50
memberRahman Mahmoodi23-Nov-15 14:50 
GeneralRe: Few questions... Pin
Shenwei Liu23-Nov-15 16:36
memberShenwei Liu23-Nov-15 16:36 
Questiondirective verticalCenter cause Infinite $digest Loop Pin
Member 107105499-Nov-15 21:01
memberMember 107105499-Nov-15 21:01 
AnswerRe: directive verticalCenter cause Infinite $digest Loop Pin
Shenwei Liu10-Nov-15 4:20
memberShenwei Liu10-Nov-15 4:20 
QuestionVery nice! Pin
Tim Kohler2-Nov-15 15:29
memberTim Kohler2-Nov-15 15:29 
QuestionDoes not work in WebStorm Pin
Michael Chao16-Sep-15 4:20
memberMichael Chao16-Sep-15 4:20 
AnswerRe: Does not work in WebStorm Pin
Shenwei Liu16-Sep-15 18:21
memberShenwei Liu16-Sep-15 18:21 
GeneralRe: Does not work in WebStorm Pin
Michael Chao17-Sep-15 11:31
memberMichael Chao17-Sep-15 11:31 
GeneralRe: Does not work in WebStorm Pin
Shenwei Liu17-Sep-15 18:10
memberShenwei Liu17-Sep-15 18:10 
Questionit does not work at all ! Pin
Member 1198353414-Sep-15 8:02
memberMember 1198353414-Sep-15 8:02 
AnswerRe: it does not work at all ! Pin
Shenwei Liu15-Sep-15 17:59
memberShenwei Liu15-Sep-15 17:59 
QuestionCan i call it using jquery? Pin
Member 118821491-Sep-15 21:53
memberMember 118821491-Sep-15 21:53 
AnswerRe: Can i call it using jquery? Pin
Shenwei Liu6-Sep-15 17:33
memberShenwei Liu6-Sep-15 17:33 
GeneralMy vote of 5 Pin
Humayun Kabir Mamun24-Aug-15 17:57
memberHumayun Kabir Mamun24-Aug-15 17:57 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web04 | 2.8.190612.1 | Last Updated 29 Mar 2017
Article Copyright 2015 by Shenwei Liu
Everything else Copyright © CodeProject, 1999-2019
Layout: fixed | fluid