forked from predixdesignsystem/px-modal
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpx-modal.html
560 lines (501 loc) · 20.5 KB
/
px-modal.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
<!--
Copyright (c) 2018, General Electric
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<link rel="import" href="../polymer/polymer.html"/>
<link rel="import" href="px-modal-trigger.html"/>
<link rel="import" href="css/px-modal-styles.html"/>
<link rel="import" href="../iron-fit-behavior/iron-fit-behavior.html"/>
<link rel="import" href="../iron-overlay-behavior/iron-focusables-helper.html">
<!--
Modals open over the page content and prompt the user to take some actions before
they continue using the app. Modals can be used to confirm the user wants to
complete a destructive action, to prompt a specific input needed before moving
forward, or to inform the user of some important information.
### Basic usage
To create a basic modal, pass some text in for the user to read and provide strings
for the accept and reject trigger buttons. Open the modal by setting the `opened`
property to `true`:
<px-modal
header-text="Confirm delete"
body-text="Do you want to delete this record? The record will be deleted permanently."
accept-text="Permanently Delete Record"
reject-text="Cancel"
opened>
</px-modal>
Use the `<px-modal-trigger>` element to create a target the user can tap to open
the modal. Data-bind the wrapper's `trigger` property to the modal's `openTrigger`
property to connect the two:
<px-modal-trigger trigger="{{trigger}}">
<button>Open the modal</button>
</px-modal-trigger>
<px-modal open-trigger="[[trigger]]" header-text="..." body-text="..." accept-text="..." reject-text="...">
</px-modal>
### Custom content
The app can pass in custom content to display for the modal header, body,
and accept and reject trigger sections. Use the `header`, `body`, `actions`,
`accept-trigger`, and `reject-trigger` slots to define custom content:
<px-modal opened>
<div slot="header">Confirm delete</div>
<div slot="body">
<p>Do you want to delete this record? The record will be deleted permanently.</p>
</div>
<div style="width:100%" slot="actions">
<button>Custom action</button>
</div>
<button slot="reject-trigger">Cancel</button>
<button slot="accept-trigger">Permanently Delete Record</button>
</px-modal>
When the user taps on elements passed into the trigger slots the modal will
automatically hide itself and fire relevant events.
### Application modal
As opposed to the modal dialog use case above, your application may need to provide a larger, more interactive
modal - the user is still focused on completing a task outside of the regular application flow, but may need more
space. An example would be drilling down from a table into a detail view for a specific row in that table,
taking some action to disposition the row, and returning the user to the main view. In this case, use the `fill-container`
and `fit-into` properties to specify what portion of the screen should be taken up by the modal
(usually the main content frame of your application):
<px-modal id="myModal" header-text="Detail view" fill-container>
<div slot="body">
// Content here
</div>
</px-modal>
<script>
var modal = document.getElementById('myModal');
var frame = document.getElementById('myApplicationFrame');
modal.set('fitInto', frame);
</script>
### Responding to accept and reject triggers
The user can accept or reject a modal prompt. Apps may want to respond differently
depending on what the user selects. For example, if the user accepts a delete
confirmation modal the app should delete the record, and if the user rejects the
modal the apps should cancel the deletion. Listen for the `px-modal-rejected`
and `px-modal-accepted` events to respond to user actions:
<px-modal
id="confirmModal"
header-text="Confirm delete"
body-text="Do you want to delete this record? The record will be deleted permanently."
accept-text="Permanently Delete Record"
reject-text="Cancel"
opened>
</px-modal>
<script>
var modal = document.getElementById('confirmModal');
modal.addEventListener('px-modal-rejected', function(e) {
// ... handle reject action here ...
});
modal.addEventListener('px-modal-accepted', function(e) {
// ... handle accept action here ...
});
</script>
By default, the modal is dismissed when the user presses the escape key. Listen
for the `px-modal-dismissed` event to respond to this action. Set the
`disableCloseOnEscape` property to `true` to keep the modal open when the
user presses the escape key.
### Styling
The following custom properties are available for styling:
Custom property | Description
:------------|:-------------
`--px-modal-background-color` | Background color of the modal dialog
`--px-modal-background-color--container` | Background color of the modal dialog when fill-container is true
`--px-modal-overlay-color` | Color of the overlay that covers the screen behind the modal dialog
`--px-modal-text-color` | Text color for content set with properties (slotted content will not use this color)
`--px-modal-text-color--container` | Text color for content set with properties when fill-container is true
`--px-modal-z-index` | z-index value for the modal
@element px-modal
@blurb Shows a modal that overlays the screen
@homepage index.html
@demo index.html
-->
<dom-module id="px-modal">
<template>
<style include="px-modal-styles"></style>
<div class="modal" id="modal" on-transitionend="_handleTransitionEnd">
<div id="box" class="modal__box shadow-modal" style="display:none">
<div>
<!-- Header -->
<div class="modal__header">
<slot name="header">
<span class="modal__header__text epsilon weight--normal">[[headerText]]</span>
</slot>
</div>
<!-- Body -->
<div class="modal__body">
<slot name="body">
<span class="modal__body__text">[[bodyText]]</span>
</slot>
</div>
</div>
<!-- Actions -->
<div class="modal__triggers">
<!-- Custom actions -->
<slot name="actions"></slot>
<!-- Actions: Reject -->
<div class="modal__reject-trigger" on-tap="_handleRejectTriggerTapped" id="reject-trigger-container">
<slot name="reject-trigger">
<button type="button" class="btn" id="reject-trigger-button">[[rejectText]]</button>
</slot>
</div>
<!-- Actions: Accept -->
<div class="modal__accept-trigger" on-tap="_handleAcceptTriggerTapped" id="accept-trigger-container">
<slot name="accept-trigger">
<button type="button" class="btn btn--call-to-action" id="accept-trigger-button">[[acceptText]]</button>
</slot>
</div>
</div>
</div>
</div>
</template>
</dom-module>
<script>
(() => {
Polymer({
is: 'px-modal',
behaviors: [Polymer.IronFitBehavior],
properties: {
/**
* String displayed in the modal header. Override with custom HTML using
* the `header` slot.
*/
headerText: String,
/**
* String displayed in the modal body. Override with custom HTML using
* the `body` slot.
*/
bodyText: String,
/**
* String used for the accept button. Override with a custom button using
* the `accept-trigger` slot.
*/
acceptText: String,
/**
* String used for the reject button. Override with a custom button using
* the `reject-trigger` slot.
*/
rejectText: String,
/**
* Set this flag to show the modal, remove it to hide the modal.
* Automatically updated when the user taps a trigger that opens or
* closes the modal.
*/
opened: {
type: Boolean,
value: false,
notify: true,
reflectToAttribute: true,
observer: '_handleOpenedChanged'
},
/**
* Set to an HTMLElement reference using JavaScript. When the element
* is clicked or tapped, the modal will be opened. To create your own
* mechanism for opening the modal use the `opened` property and
* do not set an `openTrigger`.
*/
openTrigger: {
type: HTMLElement,
observer: '_handleOpenTriggerUpdated'
},
/**
* By default the user can press escape to dismiss the modal. Set this
* property to `true` to disable that functionality if you want to
* force the user to interact with the accept/reject buttons to
* dismiss the modal.
*/
disableCloseOnEscape: {
type: Boolean,
value: false
},
/**
* By default the modal automatically focuses the first focusable
* element in its slots or shadow root. To disable this behavior set
* this property to `true`. Must be set before the modal is opened to
* work correctly.
*/
disableAutoFocus: {
type: Boolean,
value: false
},
/**
* By default, the modal will fit itself into the Window for use with
* modal dialogs. Set this property to create a modal that fills another
* container instead, for instance the main content area of your app.
* You can specify which container using the `fitInto` property.
*/
fillContainer: {
type: Boolean,
value: false,
reflectToAttribute: true,
observer: '_handleFillContainerChanged'
},
/**
* Used by iron-fit-behavior to attach the modal to the Window by default.
*/
autoFitOnAttach: {
type: Boolean,
value: true
},
/**
* Focusable elements inside the modal, determined with
* `Polymer.IronFocusablesHelper.getTabbableNodes()`.
*/
_focusableElements: {
type: Array,
value: []
}
},
observers: [
'_refit(fillContainer, fitInto)'
],
created() {
this._modalKeypressFn = this._handleModalKeypress.bind(this);
this._openTriggerTappedFn = () => {
this.opened = true
};
this._openTriggerKeydownFn = (evt) => {
if (evt.key === 'Enter') {
this.opened = true;
}
};
},
_handleOpenedChanged(opened) {
if (opened) {
// Make the box visible now so it can get animations
// On close change the display on transitionend (in `_handleTransitionEnd()`)
this.$.box.style['display'] = 'flex';
this.addEventListener('keydown', this._modalKeypressFn, false);
}
else {
this.removeEventListener('keydown', this._modalKeypressFn);
}
},
/**
* After the modal animates into visibility try to focus the first
* focusable element.
*
* After the modal animates out of visibility try to return focus to the
* document element that was focused before the modal was opened.
*/
_handleTransitionEnd(evt) {
if (Polymer.dom(evt).rootTarget === this.$.modal) {
this.debounce('handle-transition-end', () => {
if (this.opened) {
this.refit();
// When the modal first opens, find focusable elements and
// focus the first one, unless the `disableAutoFocus` boolean
// is set to `true`
this._elementFocusedBeforeOpened = document.activeElement;
this.findFocusableElements();
if (this._focusableElements.length) {
if (this._firstFocusableElement && !this.disableAutoFocus) {
this._firstFocusableElement.focus();
}
}
} else {
// Set the display back to none to make the modal invisible
this.$.box.style['display'] = 'none';
// Focus the last focused element in the document unless the
// `disableAutoFocus` boolean is set to `true`
if (this._elementFocusedBeforeOpened && !this.disableAutoFocus) {
this._elementFocusedBeforeOpened.focus();
}
this._elementFocusedBeforeOpened = null;
}
}, 1);
}
},
/**
* Find focusable elements inside the modal by using `IronFocusablesHelper`'s
* `getTabbableNodes()` helper method.
* Call this method in case you are dymanically changing the modal's content
* to update the array of focusable elements .
*/
findFocusableElements() {
this._focusableElements = Polymer.IronFocusablesHelper.getTabbableNodes(this);
if (this._focusableElements.length) {
this._firstFocusableElement = this._focusableElements[0];
this._lastFocusableElement = this._focusableElements[this._focusableElements.length-1];
} else {
this._firstFocusableElement = null;
this._lastFocusableElement = null;
}
},
/**
* Intercepts keyboard events when the modal is opened and traps user
* focus on tab. Closes the modal when the user hits escape.
*/
_handleModalKeypress(evt) {
if (evt.key === 'Tab' && !evt.shiftKey) {
/*
* When the user tabs forward past the last focusable element,
* focus the first focusable element
*/
const lastFocusedElement = Polymer.dom(evt).rootTarget;
if (lastFocusedElement) {
const lastFocusedElementIndex = this._focusableElements.indexOf(lastFocusedElement)
if (this._focusableElements[lastFocusedElementIndex + 1]) {
this._focusableElements[lastFocusedElementIndex + 1].focus()
} else {
this._firstFocusableElement.focus();
}
} else {
this._firstFocusableElement.focus()
}
evt.preventDefault();
}
if (evt.key === 'Tab' && evt.shiftKey) {
/*
* When the user tabs backward past the first focusable element,
* focus the last focusable element
*/
let lastFocusedElement = Polymer.dom(evt).rootTarget;
if (lastFocusedElement) {
const lastFocusedElementIndex = this._focusableElements.indexOf(lastFocusedElement)
if (this._focusableElements[lastFocusedElementIndex - 1]) {
this._focusableElements[lastFocusedElementIndex - 1].focus()
} else {
this._lastFocusableElement.focus();
}
} else {
this._lastFocusableElement.focus()
}
evt.preventDefault();
if (lastFocusedElement && this._lastFocusableElement && lastFocusedElement === this._firstFocusableElement) {
evt.preventDefault();
this._lastFocusableElement.focus();
}
}
if (evt.key === 'Escape' && !this.disableCloseOnEscape) {
this.opened = false;
this.fire('px-modal-dismissed');
}
},
/**
* Fired when the user presses the escape button to dismiss the modal.
* This event does not mean the user accepted or rejected the modal
* prompt. To force the user to accept/reject the modal, set the
* `disableCloseOnEscape` property to `true`.
* @event px-modal-dismissed
*/
_handleOpenTriggerUpdated(newTrigger, oldTrigger) {
if (oldTrigger) {
oldTrigger.removeEventListener('click', this._openTriggerTappedFn);
oldTrigger.removeEventListener('keydown', this._openTriggerKeydownFn);
}
if (newTrigger) {
newTrigger.addEventListener('click', this._openTriggerTappedFn, false);
newTrigger.addEventListener('keydown', this._openTriggerKeydownFn, false);
}
},
/**
* Fired when the reject trigger is tapped (after the modal is closed).
* @event px-modal-rejected
*/
_handleRejectTriggerTapped(e) {
const target = Polymer.dom(e).rootTarget;
const triggerContainer = Polymer.dom(this.root).querySelector('#reject-trigger-container');
/*
* - First check: We should only close the modal if the user clicked on
* a button, not if this click event is sourced from the container.
* - Second check: A click on a disabled input element like a <button>
* should not trigger our click listener. But it does in IE. So we
* manually check the event source, and ignore the event if the
* source has the `disabled` attribute. Bof.
*/
if (target !== triggerContainer && !target.hasAttribute('disabled')) {
this.opened = false;
this.fire('px-modal-rejected');
}
},
/**
* Fired when the accept trigger is tapped (after the modal is closed).
* @event px-modal-accepted
*/
_handleAcceptTriggerTapped(e) {
const target = Polymer.dom(e).rootTarget;
const triggerContainer = Polymer.dom(this.root).querySelector('#accept-trigger-container');
/*
* - First check: We should only close the modal if the user clicked on
* a button, not if this click event is sourced from the container.
* - Second check: A click on a disabled input element like a <button>
* should not trigger our click listener. But it does in IE. So we
* manually check the event source, and ignore the event if the
* source has the `disabled` attribute. Bof.
*/
if (target !== triggerContainer && !target.hasAttribute('disabled')) {
this.opened = false;
this.fire('px-modal-accepted');
}
},
_handleFillContainerChanged(newValue) {
if(newValue === true) {
this.listen(this.fitInto, 'scroll', '_refit');
this.listen(this.fitInto, 'resize', '_refit');
}
else {
this.unlisten(this.fitInto, 'scroll', '_refit');
this.unlisten(this.fitInto, 'resize', '_refit');
}
},
/**
* Handles refitting modals that are supposed to fill a container
* when the screen is scrolled and the container presumably moves.
*/
_refit() {
if(this.fillContainer && this.fitInto) {
this.debounce('refit', function() {
this.refit();
}, 10);
}
}
});
/*
* Array.prototype.find polyfill (for IE11) from:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find.
*/
// https://tc39.github.io/ecma262/#sec-array.prototype.find
if (!Array.prototype.find) {
Object.defineProperty(Array.prototype, 'find', {
value: function(predicate) {
// 1. Let O be ? ToObject(this value).
if (this == null) {
throw new TypeError('"this" is null or not defined');
}
var o = Object(this);
// 2. Let len be ? ToLength(? Get(O, "length")).
var len = o.length >>> 0;
// 3. If IsCallable(predicate) is false, throw a TypeError exception.
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function');
}
// 4. If thisArg was supplied, let T be thisArg; else let T be undefined.
var thisArg = arguments[1];
// 5. Let k be 0.
var k = 0;
// 6. Repeat, while k < len
while (k < len) {
// a. Let Pk be ! ToString(k).
// b. Let kValue be ? Get(O, Pk).
// c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)).
// d. If testResult is true, return kValue.
var kValue = o[k];
if (predicate.call(thisArg, kValue, k, o)) {
return kValue;
}
// e. Increase k by 1.
k++;
}
// 7. Return undefined.
return undefined;
}
});
}
})();
</script>