@madgex/design-system 13.1.0 → 13.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ const ONE_MINUTE_IN_MS = 60000;
2
+
3
+ const STATE = {
4
+ NONE: 'NONE',
5
+ WARNING: 'WARNING',
6
+ TIMED_OUT: 'TIMED_OUT',
7
+ };
8
+ /**
9
+ * Displays a Dialog box when timeout is about to expire. (representing User's Auth session (auth cookie)).
10
+ *
11
+ * This expiry time is based on attribute passed `timeout-in-milliseconds`,
12
+ * ⚠️ We dont actually read any real cookie, but assume the page rendered "timeout time" is the same as the cookie! cookie is not readable by JS ⚠️
13
+ *
14
+ * A warning Dialog is show 2 minutes before expiry (configurable via attribute `warning-in-milliseconds`).
15
+ * The warning dialog has a button which triggers an XHR to try and refresh the auth cookie by making a background request. Assumes 200 was successful auth refresh.
16
+ *
17
+ * On expiry, final Dialog is show displaying your message, e.g. "you have been signed out", with a close button
18
+ *
19
+ */
20
+ export class MdsTimeoutDialog extends HTMLElement {
21
+ #state = STATE.NONE;
22
+ #warningDialogTimeout;
23
+ #timedOutDialogTimeout;
24
+ /**
25
+ * if attributes change, we will need to re-render the template.
26
+ */
27
+ static get observedAttributes() {
28
+ return [
29
+ 't-title',
30
+ 't-title-timed-out',
31
+ 't-message',
32
+ 't-message-error-failed-refresh',
33
+ 't-btn-stay-refresh',
34
+ 't-btn-sign-out',
35
+ 't-btn-close',
36
+ 'keep-alive-url',
37
+ 'login-url',
38
+ 'logout-url',
39
+ 'warning-in-milliseconds',
40
+ 'timeout-in-milliseconds',
41
+ ];
42
+ }
43
+ constructor() {
44
+ super();
45
+ /** good practice to "bind" event listener functions, so `this` always works inside them */
46
+ this.onClickSignInButton = this.onClickSignInButton.bind(this);
47
+ this.onSignedOutDialogClose = this.onSignedOutDialogClose.bind(this);
48
+
49
+ this.dialogIdWarning = `mds-timeout-dialog-warning-${window.crypto.randomUUID()}`;
50
+ this.dialogIdSignedOut = `mds-timeout-dialog-signed-out-${window.crypto.randomUUID()}`;
51
+ }
52
+ connectedCallback() {
53
+ this.render();
54
+ this.update(STATE.NONE);
55
+ this.#setTimeouts();
56
+ }
57
+ disconnectedCallback() {
58
+ this.#clearTimeouts();
59
+ this.removeEventListeners();
60
+ this.update(STATE.NONE);
61
+ }
62
+ /**
63
+ * if attributes change, re-render template, refresh state
64
+ * @param {string} name
65
+ * @param {string} oldValue
66
+ * @param {string} newValue
67
+ */
68
+ attributeChangedCallback(name, oldValue = null, newValue = null) {
69
+ if (!this.isConnected) return; // attributes are null before connect, causing callback to fire
70
+ if (oldValue === newValue) return; // no need to re-render or update if attribute value stays the same
71
+
72
+ const dontNeedToReRenderOnThese = ['warning-in-milliseconds', 'timeout-in-milliseconds'];
73
+ if (!dontNeedToReRenderOnThese.includes(name)) {
74
+ this.render();
75
+ this.update();
76
+ } else {
77
+ console.warn(`${name} was changed which may affect timeout accuracy`);
78
+ }
79
+ }
80
+
81
+ get rootNode() {
82
+ return this;
83
+ }
84
+
85
+ get #document() {
86
+ return this.getRootNode({ composed: true });
87
+ }
88
+
89
+ /**
90
+ * Main template, which is re-rendered on attribute changes.
91
+ *
92
+ * closedby="none" means it does not close with `esc` key, we want the user to make a decision via the dialog buttons
93
+ */
94
+ get template() {
95
+ const template = this.#document.createElement('template');
96
+ template.innerHTML = /* HTML */ `
97
+ <dialog class="mds-timeout-dialog" role="alertdialog" closedby="none" aria-labelledby="${this.dialogIdWarning}">
98
+ <h2 class="mds-margin-bottom-b4" id="${this.dialogIdWarning}">${this.translations['title']}</h2>
99
+ <p>${this.translations['message']}</p>
100
+ <p class="mds-message mds-message--error mds-display-none">${this.translations['message-error-sign-in']}</p>
101
+ <div class="mds-grid-row mds-grid-center">
102
+ <button class="mds-button mds-margin-right-b4" stay-signed-in-trigger type="button">
103
+ ${this.translations['btn-stay-signed-in']}
104
+ <svg class="mds-timeout-dialog__button-icon" viewBox="0 0 100 100">
105
+ <path
106
+ fill="currentColor"
107
+ d="M100 50c0-27.6-22.4-50-50-50S0 22.4 0 50m8.5 0C8.5 27.2 27 8.5 50 8.5S91.5 27.2 91.5 50"
108
+ ></path>
109
+ </svg>
110
+ </button>
111
+ <a class="mds-button mds-button--neutral" href="${this.logoutUrl}">${this.translations['btn-sign-out']}</a>
112
+ </div>
113
+ </dialog>
114
+ <dialog role="alertdialog" class="mds-timeout-dialog" aria-labelledby="${this.dialogIdSignedOut}">
115
+ <h2 class="mds-margin-bottom-b4" id="${this.dialogIdSignedOut}">${this.translations['title-signed-out']}</h2>
116
+ <div class="mds-grid-row mds-grid-center">
117
+ <!-- will "submit" the dialog, e.g. close it without JS -->
118
+ <form method="dialog">
119
+ <button class="mds-button">${this.translations['btn-close']}</button>
120
+ </form>
121
+ </div>
122
+ </dialog>
123
+ `;
124
+ return template;
125
+ }
126
+ get elDialogWarning() {
127
+ return this.rootNode.querySelector(`dialog[aria-labelledby=${this.dialogIdWarning}]`);
128
+ }
129
+ get elDialogSignedOut() {
130
+ return this.rootNode.querySelector(`dialog[aria-labelledby=${this.dialogIdSignedOut}]`);
131
+ }
132
+ get elSignInButton() {
133
+ return this.rootNode.querySelector('[stay-signed-in-trigger]');
134
+ }
135
+ get elMessageError() {
136
+ return this.rootNode.querySelector('.mds-message--error');
137
+ }
138
+ get translations() {
139
+ return {
140
+ title: this.getAttribute('t-title') || 'You are about to be signed out',
141
+ 'title-signed-out': this.getAttribute('t-title-timed-out') || 'You have been signed out',
142
+ message: this.getAttribute('t-message') || 'For your security, we will log you out in 2 minutes.',
143
+ 'message-error-sign-in': this.getAttribute('t-message-error-failed-refresh') || 'Failed to sign in - try again',
144
+ 'btn-stay-signed-in': this.getAttribute('t-btn-stay-refresh') || 'Stay signed in',
145
+ 'btn-sign-out': this.getAttribute('t-btn-sign-out') || 'Sign out',
146
+ 'btn-close': this.getAttribute('t-btn-close') || 'close',
147
+ };
148
+ }
149
+ /** URL to hit to refresh auth cookie, when user clicks to stay signed in */
150
+ get keepAliveUrl() {
151
+ return this.getAttribute('keep-alive-url');
152
+ }
153
+ get loginUrl() {
154
+ const attr = this.getAttribute('login-url');
155
+ if (!attr) throw new Error('no attribute login-url supplied');
156
+ return attr;
157
+ }
158
+ get logoutUrl() {
159
+ const attr = this.getAttribute('logout-url');
160
+ if (!attr) throw new Error('no attribute logout-url supplied');
161
+ return attr;
162
+ }
163
+ /** How many minutes until signed out, dialog opens this many minutes before signed out */
164
+ get warningInMilliseconds() {
165
+ const attr = this.getAttribute('warning-in-milliseconds');
166
+ return attr ? parseInt(attr, 10) : ONE_MINUTE_IN_MS * 2;
167
+ }
168
+ /** number of milliseconds until user is signed out (cookie expires) */
169
+ get timeoutInMilliseconds() {
170
+ const attr = this.getAttribute('timeout-in-milliseconds');
171
+ if (!attr) throw new Error('no attribute timeout-in-milliseconds supplied');
172
+ return parseInt(attr, 10);
173
+ }
174
+
175
+ /**
176
+ * Ensure the correct dialog is open/closed based on the current state.
177
+ *
178
+ * Optionally passing a new state.
179
+ * @param {string?} newState STATE
180
+ */
181
+ update(newState) {
182
+ if (newState) {
183
+ this.#state = newState;
184
+ }
185
+ switch (this.#state) {
186
+ case STATE.NONE:
187
+ this.elMessageError?.classList.add('mds-display-none');
188
+ this.elDialogWarning?.close();
189
+ this.elDialogSignedOut?.close();
190
+ break;
191
+ case STATE.WARNING:
192
+ this.elDialogSignedOut?.close();
193
+ this.elDialogWarning?.showModal();
194
+ break;
195
+ case STATE.TIMED_OUT:
196
+ this.elDialogWarning?.close();
197
+ this.elDialogSignedOut?.showModal();
198
+ break;
199
+ default:
200
+ break;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * destroy DOM and events, and re-render them.
206
+ */
207
+ render() {
208
+ this.removeEventListeners();
209
+ this.rootNode.replaceChildren(this.template.content.cloneNode(true));
210
+
211
+ this.elSignInButton?.addEventListener('click', this.onClickSignInButton);
212
+ this.elDialogSignedOut?.addEventListener('close', this.onSignedOutDialogClose);
213
+ }
214
+
215
+ /**
216
+ * remove event listeners, not we are not destroying the rendered DOM
217
+ */
218
+ removeEventListeners() {
219
+ this.elSignInButton?.removeEventListener('click', this.onClickSignInButton);
220
+ this.elDialogSignedOut?.removeEventListener('close', this.onSignedOutDialogClose);
221
+ }
222
+
223
+ #clearTimeouts() {
224
+ clearTimeout(this.#warningDialogTimeout);
225
+ clearTimeout(this.#timedOutDialogTimeout);
226
+ }
227
+
228
+ #setTimeouts() {
229
+ this.#clearTimeouts();
230
+ this.#warningDialogTimeout = setTimeout(() => {
231
+ this.update(STATE.WARNING);
232
+ }, this.timeoutInMilliseconds - this.warningInMilliseconds);
233
+
234
+ this.#timedOutDialogTimeout = setTimeout(() => {
235
+ this.update(STATE.TIMED_OUT);
236
+ }, this.timeoutInMilliseconds);
237
+ }
238
+ /**
239
+ * If `keepAliveUrl` is populated, GET it, which should hopefully return a new logged in cookie.
240
+ * We assume a 200 response means a cookie was set, as the cookie is usually not readable via javascript.
241
+ *
242
+ * If `keepAliveUrl` is not populated, we assume external fetch will happen on `refresh` event,
243
+ * and `onRefreshSuccess` / `onRefreshFailed` will be called externally.
244
+ */
245
+ async onClickSignInButton() {
246
+ this.dispatchEvent(new CustomEvent('refresh'));
247
+ this.elDialogWarning?.classList.add('mds-timeout-dialog--refreshing');
248
+ if (!this.keepAliveUrl) return;
249
+ try {
250
+ const res = await fetch(this.keepAliveUrl);
251
+ if (!res.ok) {
252
+ const text = await res.text();
253
+ throw new Error(`Could not stay signed in - ${res.statusText} - ${text}`);
254
+ }
255
+ // assume successful response means successful cookie refresh
256
+ this.onRefreshSuccess();
257
+ } catch (error) {
258
+ this.onRefreshFailed(error);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Useful for external call when auth has been refreshed. Currently the same as `onRefreshSuccess`
264
+ */
265
+ restart() {
266
+ this.elDialogWarning?.classList.remove('mds-timeout-dialog--refreshing');
267
+ this.elMessageError?.classList.add('mds-display-none');
268
+ this.update(STATE.NONE);
269
+ this.#setTimeouts();
270
+ }
271
+ /**
272
+ * Useful for external call when auth has expired outside of this component.
273
+ */
274
+ timedOut() {
275
+ this.#clearTimeouts();
276
+ this.update(STATE.TIMED_OUT);
277
+ }
278
+
279
+ /**
280
+ * Called on successful sign in refresh. May be called externally.
281
+ */
282
+ onRefreshSuccess() {
283
+ this.restart();
284
+ }
285
+
286
+ /**
287
+ * Called on failed sign in refresh. May be called externally.
288
+ *
289
+ * @param {Error} error optional
290
+ */
291
+ onRefreshFailed(error) {
292
+ console.error(error);
293
+ // response is error, assume failed to fetch fresh cookie
294
+ this.elDialogWarning?.classList.remove('mds-timeout-dialog--refreshing');
295
+ this.elMessageError?.classList.remove('mds-display-none');
296
+ }
297
+
298
+ /**
299
+ * when the 'signed out dialog' gets closed, we want to send the user straight to the log in page.
300
+ * This is because we dont want the user to close the dialog and be left with a visually signed in page.
301
+ * PipelinedPage return URL back to this current page URL after log in
302
+ */
303
+ onSignedOutDialogClose() {
304
+ // ensure if the signed out dialog is closed, it was only in state `TIMED_OUT`
305
+ if (this.#state === STATE.TIMED_OUT) {
306
+ window.location.href = `${this.loginUrl}?PipelinedPage=${encodeURIComponent(window.location.href)}`;
307
+ }
308
+ }
309
+ }
@@ -0,0 +1,109 @@
1
+
2
+ <style>
3
+ /* Intentionally gross styles to test shadow DOM isolation */
4
+ .blurb {
5
+ margin-bottom: 50px;
6
+ }
7
+ .blurb, .blurb * {
8
+ font-family: 'comic sans ms' !important;
9
+ }
10
+ .blurb button {
11
+ transform: rotate(-2.5deg) !important;
12
+ }
13
+
14
+
15
+ dialog {
16
+ border: 10px solid red !important;
17
+ background: yellow !important;
18
+ padding: 20px !important;
19
+ }
20
+
21
+ dialog h2 {
22
+ color: red !important;
23
+ font-size: 40px !important;
24
+ font-family: 'Comic Sans MS', cursive !important;
25
+ text-decoration: underline !important;
26
+ }
27
+
28
+ dialog p {
29
+ color: green !important;
30
+ }
31
+
32
+ .mds-button {
33
+ background: magenta !important;
34
+ transform: rotate(-2.5deg) !important;
35
+ }
36
+ </style>
37
+
38
+ <nav class="position-relative mds-margin-bottom-b12">
39
+ <div id="inject-container" style="position:relative;">
40
+ {# <template shadowrootmode="open">
41
+ <mds-timeout-dialog-standalone
42
+ login-url="/login"
43
+ logout-url="/logoff"
44
+ keep-alive-url="/"
45
+ timeout-in-milliseconds="24000"
46
+ warning-in-milliseconds="23000"
47
+ t-title="You are about to be signed out"
48
+ t-title-timed-out="You have been signed out"
49
+ t-message="For your security, we will log you out in ?? minutes."
50
+ t-message-error-failed-refresh="Failed to sign in - try again"
51
+ t-btn-stay-refresh="Stay signed in"
52
+ t-btn-sign-out="Sign out"
53
+ t-btn-close="close"></mds-timeout-dialog-standalone>
54
+ </template> #}
55
+ </div>
56
+
57
+ <p class="mds-margin-bottom-b12">
58
+ This page has some gross styles for dialog elements. The LightDOM version will inherit these styles but the
59
+ shadow DOM version should be isolated from these styles & wupply its own.
60
+ </p>
61
+
62
+ <div class="blurb">
63
+ <h2>LightDOM Version</h2>
64
+ <pre><code>&lt;mds-timeout-dialog&gt;&lt;/mds-timeout-dialog&gt;</code></pre>
65
+ <p>
66
+ <button onclick="testRegularTimeOutDialog()">Inject TimeOut Dialog</button>
67
+ </p>
68
+
69
+ <br>
70
+
71
+ <h2>Standalone/ShadowDOM Version</h2>
72
+ <pre><code>&lt;mds-timeout-dialog-standalone&gt;&lt;/mds-timeout-dialog-standalone&gt;</code></pre>
73
+ <p>
74
+ <button onclick="testStandaloneTimeOutDialog()">Inject TimeOut Dialog</button>
75
+ </p>
76
+ </div>
77
+ </nav>
78
+
79
+ <script>
80
+ function testRegularTimeOutDialog() {
81
+ const regularElement = document.createElement('mds-timeout-dialog');
82
+ regularElement.setAttribute('login-url', '/login');
83
+ regularElement.setAttribute('logout-url', '/logout');
84
+ regularElement.setAttribute('keep-alive-url', '/?keepalive');
85
+ regularElement.setAttribute('timeout-in-milliseconds', '6000');
86
+ regularElement.setAttribute('warning-in-milliseconds', '5500');
87
+ regularElement.setAttribute('t-title', 'Regular Version - Light DOM');
88
+ regularElement.setAttribute('t-message', 'This dialog uses LIGHT DOM and will be styled by page CSS');
89
+
90
+ document.getElementById('inject-container').innerHTML = '';
91
+ document.getElementById('inject-container').appendChild(regularElement);
92
+ console.log(regularElement);
93
+ }
94
+
95
+ function testStandaloneTimeOutDialog() {
96
+ const standaloneElement = document.createElement('mds-timeout-dialog-standalone');
97
+ standaloneElement.setAttribute('login-url', '/login');
98
+ standaloneElement.setAttribute('logout-url', '/logout');
99
+ standaloneElement.setAttribute('keep-alive-url', '/?keepalive');
100
+ standaloneElement.setAttribute('timeout-in-milliseconds', '6000');
101
+ standaloneElement.setAttribute('warning-in-milliseconds', '5500');
102
+ standaloneElement.setAttribute('t-title', 'Standalone Version - Shadow DOM');
103
+ standaloneElement.setAttribute('t-message', 'This dialog uses SHADOW DOM and should be isolated from page CSS');
104
+
105
+ document.getElementById('inject-container').innerHTML = '';
106
+ document.getElementById('inject-container').appendChild(standaloneElement);
107
+ console.log(standaloneElement);
108
+ }
109
+ </script>
@@ -0,0 +1,34 @@
1
+ /* making standard browser <dialog> look a bit more like mds-modal - maybe mds-modal should be replaced with <dialog> and this small amount of CSS? */
2
+ .mds-timeout-dialog {
3
+ background-color: #fff;
4
+ margin: ($constant-size-baseline * 10) auto;
5
+ padding: ($constant-size-baseline * 8) ($constant-size-baseline * 12);
6
+ border-radius: $border-radius;
7
+ border: 0;
8
+ max-width: 800px;
9
+ position: relative;
10
+ }
11
+ .mds-timeout-dialog::backdrop {
12
+ background-color: rgba(0, 0, 0, 0.8);
13
+ }
14
+
15
+ .mds-timeout-dialog__button-icon {
16
+ display: none;
17
+ width: 1em;
18
+ height: 1em;
19
+ animation: spin-timeout-dialog 1s infinite linear;
20
+ color: inherit;
21
+ margin-left: 4px;
22
+ }
23
+ .mds-timeout-dialog--refreshing .mds-timeout-dialog__button-icon {
24
+ display: inline-block;
25
+ }
26
+
27
+ @keyframes spin-timeout-dialog {
28
+ 0% {
29
+ transform: rotate(0);
30
+ }
31
+ 100% {
32
+ transform: rotate(360deg);
33
+ }
34
+ }
@@ -1,7 +1,8 @@
1
1
  import switchStateScript from './fractal-scripts/switch-state';
2
2
  import notificationScript from './fractal-scripts/notification';
3
3
 
4
- document.addEventListener('DOMContentLoaded', () => {
4
+ document.addEventListener('DOMContentLoaded', async () => {
5
5
  switchStateScript.init();
6
6
  notificationScript.init();
7
+ await import('../components/timeout-dialog/mds-timeout-dialog-standalone.js');
7
8
  });
package/src/js/index.js CHANGED
@@ -10,12 +10,15 @@ import characterCount from '../components/inputs/textarea/character-count';
10
10
  import button from '../components/button/button';
11
11
  import prose from '../helpers/prose/prose';
12
12
  import { MdsDropdownNav } from '../components/dropdown-nav/dropdown-nav';
13
+ import { MdsTimeoutDialog } from '../components/timeout-dialog/timeout-dialog';
13
14
  import { MdsCardLink } from '../components/card/card-link';
14
15
 
15
16
  if (!window.customElements.get('mds-dropdown-nav')) {
16
17
  window.customElements.define('mds-dropdown-nav', MdsDropdownNav);
17
18
  }
18
-
19
+ if (!window.customElements.get('mds-timeout-dialog')) {
20
+ window.customElements.define('mds-timeout-dialog', MdsTimeoutDialog);
21
+ }
19
22
  if (!window.customElements.get('mds-card-link')) {
20
23
  window.customElements.define('mds-card-link', MdsCardLink);
21
24
  }
@@ -17,3 +17,4 @@
17
17
  @import '../../components/modal/modal';
18
18
  @import '../../components/skip-link/skip-link';
19
19
  @import '../../components/media-block/media-block';
20
+ @import '../../components/timeout-dialog/timeout-dialog';