-
Notifications
You must be signed in to change notification settings - Fork 8
/
Copy pathtimeout-warning.js
403 lines (342 loc) · 14.5 KB
/
timeout-warning.js
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
import '../../vendor/polyfills/Function/prototype/bind'
import '../../vendor/polyfills/Element/prototype/classList'
function TimeoutWarning ($module) {
this.$module = $module
this.$lastFocusedEl = null
this.$closeButton = $module.querySelector('.js-dialog-close')
this.$cancelButton = $module.querySelector('.js-dialog-cancel')
this.overLayClass = 'govuk-timeout-warning-overlay'
this.$fallBackElement = document.querySelector('.govuk-timeout-warning-fallback')
this.timers = []
// UI countdown timer specific markup
this.$countdown = $module.querySelector('.timer')
this.$accessibleCountdown = $module.querySelector('.at-timer')
// UI countdown specific settings
this.idleMinutesBeforeTimeOut = $module.getAttribute('data-minutes-idle-timeout') ? $module.getAttribute('data-minutes-idle-timeout') : 25
this.timeOutRedirectUrl = $module.getAttribute('data-url-redirect') ? $module.getAttribute('data-url-redirect') : 'timeout'
this.minutesTimeOutModalVisible = $module.getAttribute('data-minutes-modal-visible') ? $module.getAttribute('data-minutes-modal-visible') : 5
this.timeUserLastInteractedWithPage = ''
}
// Initialise component
TimeoutWarning.prototype.init = function () {
// Check for module
if (!this.$module) {
return
}
// Check that dialog element has native or polyfill support
if (!this.dialogSupported()) {
return
}
// Start watching for idleness
this.countIdleTime()
this.$closeButton.addEventListener('click', this.closeDialog.bind(this))
this.$module.addEventListener('keydown', this.escClose.bind(this))
// Debugging tip: This event doesn't kick in in Chrome if you have Inspector panel open and have clicked on it
// as it is now the active element. Click on the window to make it active before moving to another tab.
window.addEventListener('focus', this.checkIfShouldHaveTimedOut.bind(this))
}
// Check if browser supports native dialog element or can use polyfill
TimeoutWarning.prototype.dialogSupported = function () {
if (typeof HTMLDialogElement === 'function') {
// Native dialog is supported by browser
return true
} else {
// Native dialog is not supported by browser so use polyfill
try {
window.dialogPolyfill.registerDialog(this.$module)
return true
} catch (error) {
// Doesn't support polyfill (IE8) - display fallback element
this.$fallBackElement.classList.add('govuk-!-display-block')
return false
}
}
}
// Count idle time (user not interacting with page)
// Reset idle time counter when user interacts with the page
// If user is idle for specified time period, open timeout warning as dialog
TimeoutWarning.prototype.countIdleTime = function () {
var idleTime
var milliSecondsBeforeTimeOut = this.idleMinutesBeforeTimeOut * 60000
// As user interacts with the page, keep resetting the timer
window.onload = resetIdleTime.bind(this)
window.onmousemove = resetIdleTime.bind(this)
window.onmousedown = resetIdleTime.bind(this) // Catches touchscreen presses
window.onclick = resetIdleTime.bind(this) // Catches touchpad clicks
window.onscroll = resetIdleTime.bind(this) // Catches scrolling with arrow keys
window.onkeypress = resetIdleTime.bind(this)
window.onkeyup = resetIdleTime.bind(this) // Catches Android keypad presses
function resetIdleTime () {
// As user has interacted with the page, reset idle time
clearTimeout(idleTime)
// Start new idle time
idleTime = setTimeout(this.openDialog.bind(this), milliSecondsBeforeTimeOut)
// TO DO - Step A of client/server interaction
// Set last interactive time on server by periodically ping server
// with AJAX when user interacts with client side
// See setLastActiveTimeOnServer()
if (window.localStorage) {
window.localStorage.setItem('timeUserLastInteractedWithPage', new Date())
}
}
}
TimeoutWarning.prototype.openDialog = function () {
// TO DO - Step B of client/server interaction
// GET last interactive time from server before showing warning
// User could be interacting with site in 2nd tab
// Update time left accordingly
if (!this.isDialogOpen()) {
document.querySelector('body').classList.add(this.overLayClass)
this.saveLastFocusedEl()
this.makePageContentInert()
this.$module.showModal()
this.startUiCountdown()
// if (window.history.pushState) {
// window.history.pushState('', '') // This updates the History API to enable state to be "popped" to detect browser navigation for disableBackButtonWhenOpen
// }
}
}
// Starts a UI countdown timer. If timer is not cancelled before 0
// reached + 4 seconds grace period, user is redirected.
TimeoutWarning.prototype.startUiCountdown = function () {
this.clearTimers() // Clear any other modal timers that might have been running
var $module = this
var $countdown = this.$countdown
var $accessibleCountdown = this.$accessibleCountdown
var minutes = this.minutesTimeOutModalVisible
var timerRunOnce = false
var iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
var timers = this.timers
var seconds = 60 * minutes
$countdown.innerHTML = minutes + ' minute' + (minutes > 1 ? 's' : '');
(function runTimer () {
var minutesLeft = parseInt(seconds / 60, 10)
var secondsLeft = parseInt(seconds % 60, 10)
var timerExpired = minutesLeft < 1 && secondsLeft < 1
var minutesText = minutesLeft > 0 ? '<span class="tabular-numbers">' + minutesLeft + '</span> minute' + (minutesLeft > 1 ? 's' : '') + '' : ' '
var secondsText = secondsLeft >= 1 ? ' <span class="tabular-numbers">' + secondsLeft + '</span> second' + (secondsLeft > 1 ? 's' : '') + '' : ''
var atMinutesNumberAsText = ''
var atSecondsNumberAsText = ''
try {
atMinutesNumberAsText = this.numberToWords(minutesLeft) // Attempt to convert numerics into text as iOS VoiceOver ccassionally stalled when encountering numbers
atSecondsNumberAsText = this.numberToWords(secondsLeft)
} catch (e) {
atMinutesNumberAsText = minutesLeft
atSecondsNumberAsText = secondsLeft
}
var atMinutesText = minutesLeft > 0 ? atMinutesNumberAsText + ' minute' + (minutesLeft > 1 ? 's' : '') + '' : ''
var atSecondsText = secondsLeft >= 1 ? ' ' + atSecondsNumberAsText + ' second' + (secondsLeft > 1 ? 's' : '') + '' : ''
// Below string will get read out by screen readers every time the timeout refreshes (every 15 secs. See below).
// Please add additional information in the modal body content or in below extraText which will get announced to AT the first time the time out opens
var text = 'We will reset your application if you do not respond in ' + minutesText + secondsText + '.'
var atText = 'We will reset your application if you do not respond in ' + atMinutesText
if (atSecondsText) {
if (minutesLeft > 0) {
atText += ' and'
}
atText += atSecondsText + '.'
} else {
atText += '.'
}
var extraText = ' We do this to keep your information secure.'
if (timerExpired) {
// TO DO - client/server interaction
// GET last interactive time from server before timing out user
// to ensure that user hasn’t interacted with site in another tab
$countdown.innerHTML = 'You are about to be redirected'
$accessibleCountdown.innerHTML = 'You are about to be redirected'
setTimeout($module.redirect.bind($module), 4000)
} else {
seconds--
$countdown.innerHTML = text + extraText
if (minutesLeft < 1 && secondsLeft < 20) {
$accessibleCountdown.setAttribute('aria-live', 'assertive')
}
if (!timerRunOnce) {
// Read out the extra content only once. Don't read out on iOS VoiceOver which stalls on the longer text
if (iOS) {
$accessibleCountdown.innerHTML = atText
} else {
$accessibleCountdown.innerHTML = atText + extraText
}
timerRunOnce = true
} else if (secondsLeft % 15 === 0) {
// Update screen reader friendly content every 15 secs
$accessibleCountdown.innerHTML = atText
}
// TO DO - client/server interaction
// GET last interactive time from server while the warning is being displayed.
// If user interacts with site in second tab, warning should be dismissed.
// Compare what server returned to what is stored in client
// If needed, call this.closeDialog()
// JS doesn't allow resetting timers globally so timers need to be retained for resetting.
timers.push(setTimeout(runTimer, 1000))
}
})()
}
TimeoutWarning.prototype.saveLastFocusedEl = function () {
this.$lastFocusedEl = document.activeElement
if (!this.$lastFocusedEl || this.$lastFocusedEl === document.body) {
this.$lastFocusedEl = null
} else if (document.querySelector) {
this.$lastFocusedEl = document.querySelector(':focus')
}
}
// Set focus back on last focused el when modal closed
TimeoutWarning.prototype.setFocusOnLastFocusedEl = function () {
if (this.$lastFocusedEl) {
window.setTimeout(function () {
this.$lastFocusedEl.focus()
}, 0)
}
}
// Set page content to inert to indicate to screenreaders it's inactive
// NB: This will look for #content for toggling inert state
TimeoutWarning.prototype.makePageContentInert = function () {
if (document.querySelector('#content')) {
document.querySelector('#content').inert = true
document.querySelector('#content').setAttribute('aria-hidden', 'true')
}
}
// Make page content active when modal is not open
// NB: This will look for #content for toggling inert state
TimeoutWarning.prototype.removeInertFromPageContent = function () {
if (document.querySelector('#content')) {
document.querySelector('#content').inert = false
document.querySelector('#content').setAttribute('aria-hidden', 'false')
}
}
TimeoutWarning.prototype.isDialogOpen = function () {
return this.$module['open']
}
TimeoutWarning.prototype.closeDialog = function () {
if (this.isDialogOpen()) {
document.querySelector('body').classList.remove(this.overLayClass)
this.$module.close()
this.setFocusOnLastFocusedEl()
this.removeInertFromPageContent()
this.clearTimers()
}
}
// Clears modal timer
TimeoutWarning.prototype.clearTimers = function () {
for (var i = 0; i < this.timers.length; i++) {
clearTimeout(this.timers[i])
}
}
TimeoutWarning.prototype.disableBackButtonWhenOpen = function () {
window.addEventListener('popstate', function () {
if (this.isDialogOpen()) {
this.closeDialog()
} else {
window.history.go(-1)
}
})
}
// Close modal when ESC pressed
TimeoutWarning.prototype.escClose = function (event) {
// get the target element
if (this.isDialogOpen() && event.keyCode === 27) {
this.closeDialog()
}
}
// Do a timestamp comparison with server when the page regains focus to check
// if the user should have been timed out already.
// This could happen but because the computer went to sleep, the browser crashed etc.
TimeoutWarning.prototype.checkIfShouldHaveTimedOut = function () {
if (window.localStorage) {
// TO DO - client/server interaction
// GET last interactive time from server before timing out user
// to ensure that user hasn’t interacted with site in another tab
// If less time than data-minutes-idle-timeout left, call this.openDialog.bind(this)
var timeUserLastInteractedWithPage = new Date(window.localStorage.getItem('timeUserLastInteractedWithPage'))
var seconds = Math.abs((timeUserLastInteractedWithPage - new Date()) / 1000)
// TO DO: use both idlemin and timemodalvisible
if (seconds > this.idleMinutesBeforeTimeOut * 60) {
// if (seconds > 60) {
this.redirect.bind(this)
}
}
}
TimeoutWarning.prototype.redirect = function () {
window.location.replace(this.timeOutRedirectUrl)
}
// Example function for sending last active time of user to server
TimeoutWarning.prototype.setLastActiveTimeOnServer = function () {
// var xhttp = new XMLHttpRequest()
// xhttp.onreadystatechange = function () {
// if (this.readyState === 4 && this.status === 200) {
// var timeUserLastInteractedWithPage = new Date()
// }
// }
//
// xhttp.open('POST', 'update-time-user-interacted-with-page.rb', true)
// xhttp.send()
}
TimeoutWarning.prototype.numberToWords = function () {
var string = n.toString()
var units
var tens
var scales
var start
var end
var chunks
var chunksLen
var chunk
var ints
var i
var word
var words = 'and'
if (parseInt(string) === 0) {
return 'zero'
}
/* Array of units as words */
units = [ '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen' ]
/* Array of tens as words */
tens = [ '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety' ]
/* Array of scales as words */
scales = [ '', 'thousand', 'million', 'billion', 'trillion', 'quadrillion', 'quintillion', 'sextillion', 'septillion', 'octillion', 'nonillion', 'decillion', 'undecillion', 'duodecillion', 'tredecillion', 'quatttuor-decillion', 'quindecillion', 'sexdecillion', 'septen-decillion', 'octodecillion', 'novemdecillion', 'vigintillion', 'centillion' ]
/* Split user arguemnt into 3 digit chunks from right to left */
start = string.length
chunks = []
while (start > 0) {
end = start
chunks.push(string.slice((start = Math.max(0, start - 3)), end))
}
/* Check if function has enough scale words to be able to stringify the user argument */
chunksLen = chunks.length
if (chunksLen > scales.length) {
return ''
}
/* Stringify each integer in each chunk */
words = []
for (i = 0; i < chunksLen; i++) {
chunk = parseInt(chunks[i])
if (chunk) {
/* Split chunk into array of individual integers */
ints = chunks[i].split('').reverse().map(parseFloat)
/* If tens integer is 1, i.e. 10, then add 10 to units integer */
if (ints[1] === 1) {
ints[0] += 10
}
/* Add scale word if chunk is not zero and array item exists */
if ((word = scales[i])) {
words.push(word)
}
/* Add unit word if array item exists */
if ((word = units[ints[0]])) {
words.push(word)
}
/* Add tens word if array item exists */
if ((word = tens[ ints[1] ])) {
words.push(word)
}
/* Add hundreds word if array item exists */
if ((word = units[ints[2]])) {
words.push(word + ' hundred')
}
}
}
return words.reverse().join(' ')
}
export default TimeoutWarning