@financial-times/n-myft-ui 28.2.0 → 28.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -65,6 +65,7 @@
65
65
  "karma-sinon": "^1.0.5",
66
66
  "karma-sinon-chai": "2.0.2",
67
67
  "karma-sourcemap-loader": "^0.3.7",
68
+ "karma-viewport": "^1.0.9",
68
69
  "karma-webpack": "^4.0.2",
69
70
  "lintspaces-cli": "^0.7.0",
70
71
  "lolex": "5.1.1",
@@ -3383,6 +3384,84 @@
3383
3384
  "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
3384
3385
  "dev": true
3385
3386
  },
3387
+ "node_modules/@types/karma": {
3388
+ "version": "6.3.3",
3389
+ "resolved": "https://registry.npmjs.org/@types/karma/-/karma-6.3.3.tgz",
3390
+ "integrity": "sha512-nRMec4mTCt+tkpRqh5/pAxmnjzEgAaalIq7mdfLFH88gSRC8+bxejLiSjHMMT/vHIhJHqg4GPIGCnCFbwvDRww==",
3391
+ "dev": true,
3392
+ "dependencies": {
3393
+ "@types/node": "*",
3394
+ "log4js": "^6.4.1"
3395
+ }
3396
+ },
3397
+ "node_modules/@types/karma/node_modules/date-format": {
3398
+ "version": "4.0.13",
3399
+ "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.13.tgz",
3400
+ "integrity": "sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ==",
3401
+ "dev": true,
3402
+ "engines": {
3403
+ "node": ">=4.0"
3404
+ }
3405
+ },
3406
+ "node_modules/@types/karma/node_modules/flatted": {
3407
+ "version": "3.2.6",
3408
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.6.tgz",
3409
+ "integrity": "sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==",
3410
+ "dev": true
3411
+ },
3412
+ "node_modules/@types/karma/node_modules/fs-extra": {
3413
+ "version": "8.1.0",
3414
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
3415
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
3416
+ "dev": true,
3417
+ "dependencies": {
3418
+ "graceful-fs": "^4.2.0",
3419
+ "jsonfile": "^4.0.0",
3420
+ "universalify": "^0.1.0"
3421
+ },
3422
+ "engines": {
3423
+ "node": ">=6 <7 || >=8"
3424
+ }
3425
+ },
3426
+ "node_modules/@types/karma/node_modules/jsonfile": {
3427
+ "version": "4.0.0",
3428
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
3429
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
3430
+ "dev": true,
3431
+ "optionalDependencies": {
3432
+ "graceful-fs": "^4.1.6"
3433
+ }
3434
+ },
3435
+ "node_modules/@types/karma/node_modules/log4js": {
3436
+ "version": "6.6.1",
3437
+ "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.6.1.tgz",
3438
+ "integrity": "sha512-J8VYFH2UQq/xucdNu71io4Fo+purYYudyErgBbswWKO0MC6QVOERRomt5su/z6d3RJSmLyTGmXl3Q/XjKCf+/A==",
3439
+ "dev": true,
3440
+ "dependencies": {
3441
+ "date-format": "^4.0.13",
3442
+ "debug": "^4.3.4",
3443
+ "flatted": "^3.2.6",
3444
+ "rfdc": "^1.3.0",
3445
+ "streamroller": "^3.1.2"
3446
+ },
3447
+ "engines": {
3448
+ "node": ">=8.0"
3449
+ }
3450
+ },
3451
+ "node_modules/@types/karma/node_modules/streamroller": {
3452
+ "version": "3.1.2",
3453
+ "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.2.tgz",
3454
+ "integrity": "sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==",
3455
+ "dev": true,
3456
+ "dependencies": {
3457
+ "date-format": "^4.0.13",
3458
+ "debug": "^4.3.4",
3459
+ "fs-extra": "^8.1.0"
3460
+ },
3461
+ "engines": {
3462
+ "node": ">=8.0"
3463
+ }
3464
+ },
3386
3465
  "node_modules/@types/mdast": {
3387
3466
  "version": "3.0.10",
3388
3467
  "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -12267,6 +12346,15 @@
12267
12346
  "graceful-fs": "^4.1.6"
12268
12347
  }
12269
12348
  },
12349
+ "node_modules/jsonschema": {
12350
+ "version": "1.4.1",
12351
+ "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz",
12352
+ "integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==",
12353
+ "dev": true,
12354
+ "engines": {
12355
+ "node": "*"
12356
+ }
12357
+ },
12270
12358
  "node_modules/jsprim": {
12271
12359
  "version": "1.4.2",
12272
12360
  "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
@@ -12441,6 +12529,16 @@
12441
12529
  "graceful-fs": "^4.1.2"
12442
12530
  }
12443
12531
  },
12532
+ "node_modules/karma-viewport": {
12533
+ "version": "1.0.9",
12534
+ "resolved": "https://registry.npmjs.org/karma-viewport/-/karma-viewport-1.0.9.tgz",
12535
+ "integrity": "sha512-E1xVe66vBQtI66TGOtZMzV5nf6BW5tW4TQVUqPK+oakVLdsG/ZUG688tGK0lL1q0t7nfQD1dwLD8Z9Guu/RVdg==",
12536
+ "dev": true,
12537
+ "dependencies": {
12538
+ "@types/karma": "^6.3.3",
12539
+ "jsonschema": "^1.4.0"
12540
+ }
12541
+ },
12444
12542
  "node_modules/karma-webpack": {
12445
12543
  "version": "4.0.2",
12446
12544
  "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-4.0.2.tgz",
@@ -25822,6 +25920,74 @@
25822
25920
  "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
25823
25921
  "dev": true
25824
25922
  },
25923
+ "@types/karma": {
25924
+ "version": "6.3.3",
25925
+ "resolved": "https://registry.npmjs.org/@types/karma/-/karma-6.3.3.tgz",
25926
+ "integrity": "sha512-nRMec4mTCt+tkpRqh5/pAxmnjzEgAaalIq7mdfLFH88gSRC8+bxejLiSjHMMT/vHIhJHqg4GPIGCnCFbwvDRww==",
25927
+ "dev": true,
25928
+ "requires": {
25929
+ "@types/node": "*",
25930
+ "log4js": "^6.4.1"
25931
+ },
25932
+ "dependencies": {
25933
+ "date-format": {
25934
+ "version": "4.0.13",
25935
+ "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.13.tgz",
25936
+ "integrity": "sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ==",
25937
+ "dev": true
25938
+ },
25939
+ "flatted": {
25940
+ "version": "3.2.6",
25941
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.6.tgz",
25942
+ "integrity": "sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==",
25943
+ "dev": true
25944
+ },
25945
+ "fs-extra": {
25946
+ "version": "8.1.0",
25947
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
25948
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
25949
+ "dev": true,
25950
+ "requires": {
25951
+ "graceful-fs": "^4.2.0",
25952
+ "jsonfile": "^4.0.0",
25953
+ "universalify": "^0.1.0"
25954
+ }
25955
+ },
25956
+ "jsonfile": {
25957
+ "version": "4.0.0",
25958
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
25959
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
25960
+ "dev": true,
25961
+ "requires": {
25962
+ "graceful-fs": "^4.1.6"
25963
+ }
25964
+ },
25965
+ "log4js": {
25966
+ "version": "6.6.1",
25967
+ "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.6.1.tgz",
25968
+ "integrity": "sha512-J8VYFH2UQq/xucdNu71io4Fo+purYYudyErgBbswWKO0MC6QVOERRomt5su/z6d3RJSmLyTGmXl3Q/XjKCf+/A==",
25969
+ "dev": true,
25970
+ "requires": {
25971
+ "date-format": "^4.0.13",
25972
+ "debug": "^4.3.4",
25973
+ "flatted": "^3.2.6",
25974
+ "rfdc": "^1.3.0",
25975
+ "streamroller": "^3.1.2"
25976
+ }
25977
+ },
25978
+ "streamroller": {
25979
+ "version": "3.1.2",
25980
+ "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.2.tgz",
25981
+ "integrity": "sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==",
25982
+ "dev": true,
25983
+ "requires": {
25984
+ "date-format": "^4.0.13",
25985
+ "debug": "^4.3.4",
25986
+ "fs-extra": "^8.1.0"
25987
+ }
25988
+ }
25989
+ }
25990
+ },
25825
25991
  "@types/mdast": {
25826
25992
  "version": "3.0.10",
25827
25993
  "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -33156,6 +33322,12 @@
33156
33322
  "universalify": "^0.1.2"
33157
33323
  }
33158
33324
  },
33325
+ "jsonschema": {
33326
+ "version": "1.4.1",
33327
+ "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz",
33328
+ "integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==",
33329
+ "dev": true
33330
+ },
33159
33331
  "jsprim": {
33160
33332
  "version": "1.4.2",
33161
33333
  "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
@@ -33415,6 +33587,16 @@
33415
33587
  "graceful-fs": "^4.1.2"
33416
33588
  }
33417
33589
  },
33590
+ "karma-viewport": {
33591
+ "version": "1.0.9",
33592
+ "resolved": "https://registry.npmjs.org/karma-viewport/-/karma-viewport-1.0.9.tgz",
33593
+ "integrity": "sha512-E1xVe66vBQtI66TGOtZMzV5nf6BW5tW4TQVUqPK+oakVLdsG/ZUG688tGK0lL1q0t7nfQD1dwLD8Z9Guu/RVdg==",
33594
+ "dev": true,
33595
+ "requires": {
33596
+ "@types/karma": "^6.3.3",
33597
+ "jsonschema": "^1.4.0"
33598
+ }
33599
+ },
33418
33600
  "karma-webpack": {
33419
33601
  "version": "4.0.2",
33420
33602
  "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-4.0.2.tgz",
@@ -4,7 +4,8 @@
4
4
  data-myft-ui="saved"
5
5
  action="/myft/save/{{contentId}}"
6
6
  data-js-action="/__myft/api/core/saved/content/{{contentId}}?method=put"
7
- {{#if @root.flags.manageArticleLists}}data-myft-ui-save-new="manageArticleLists"{{/if}}>
7
+ {{#if @root.flags.manageArticleLists}}data-myft-ui-save-new="manageArticleLists"{{/if}}
8
+ {{#if @root.flags.manageArticleLists}}data-myft-ui-save-new-config="{{#if @root.flags.myftListPublicPrivateToggle}}showPublicToggle{{/if}}"{{/if}}>
8
9
  {{> n-myft-ui/components/csrf-token/input}}
9
10
  <div
10
11
  class="n-myft-ui__announcement o-normalise-visually-hidden"
package/karma.conf.js CHANGED
@@ -10,7 +10,7 @@ module.exports = function (karma) {
10
10
 
11
11
  // frameworks to use
12
12
  // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
13
- frameworks: ['mocha', 'chai', 'sinon', 'sinon-chai'],
13
+ frameworks: ['mocha', 'chai', 'sinon', 'sinon-chai', 'viewport'],
14
14
 
15
15
  // list of files / patterns to load in the browser
16
16
  files: [
@@ -110,7 +110,8 @@ module.exports = function (karma) {
110
110
  require('karma-webpack'),
111
111
  require('karma-chrome-launcher'),
112
112
  require('karma-browserstack-launcher'),
113
- require('karma-html-reporter')
113
+ require('karma-html-reporter'),
114
+ require('karma-viewport')
114
115
  ],
115
116
  client: {
116
117
  mocha: {
package/myft/main.scss CHANGED
@@ -183,6 +183,26 @@ $spacing-unit: 20px;
183
183
  }
184
184
  }
185
185
 
186
+ .myft-ui-create-list-variant-message {
187
+ border-radius: 10px;
188
+ border: 1px solid oColorsByName('black-5');
189
+ background: oColorsByName('white-80');
190
+
191
+ &-content {
192
+ display: flex;
193
+ flex-direction: column;
194
+
195
+ h3 {
196
+ margin: 0;
197
+ }
198
+ }
199
+
200
+ &-buttons {
201
+ text-align: center;
202
+ }
203
+ }
204
+
205
+
186
206
  .myft-ui-create-list-variant {
187
207
  border-radius: 10px;
188
208
  border: 1px solid oColorsByName('black-5');
@@ -239,7 +259,7 @@ $spacing-unit: 20px;
239
259
  }
240
260
 
241
261
  &-add-description {
242
- margin: 4px 0;
262
+ margin: oSpacingByName('s1') 0;
243
263
  }
244
264
 
245
265
  &-heading {
@@ -258,7 +278,7 @@ $spacing-unit: 20px;
258
278
 
259
279
  &-footer {
260
280
  border-top: 1px solid oColorsByName('black-5');
261
- padding: 16px;
281
+ padding: oSpacingByName('s4');
262
282
  }
263
283
 
264
284
  &-icon {
@@ -288,34 +308,54 @@ $spacing-unit: 20px;
288
308
  }
289
309
 
290
310
  &-form {
311
+ $field-spacing: 's4';
291
312
  display: flex;
313
+ flex-direction: column;
292
314
  width: calc(100% - 32px);
293
- justify-content: space-between;
294
- height: 40px;
295
- gap: 8px;
296
- padding: 0 16px 16px;
315
+ gap: oSpacingByName($field-spacing);
316
+ padding: 0 oSpacingByName($field-spacing) oSpacingByName('s3');
297
317
 
298
318
  & > * {
299
319
  flex: 1 1 auto;
320
+ margin-bottom: 0;
300
321
  }
301
322
 
302
323
  .o-forms-input {
303
324
  margin-top: 0;
304
325
  }
326
+
327
+ &-toggle {
328
+ position: absolute;
329
+ }
330
+
331
+ &-toggle-label::after {
332
+ background-color: oColorsByName('white');
333
+ }
334
+
335
+ &-buttons {
336
+ display: flex;
337
+ justify-content: flex-end;
338
+ @include oTypographySans($scale: 2);
339
+ }
340
+
341
+ &-public {
342
+ max-width: 300px;
343
+ padding: 0 3px;
344
+ }
305
345
  }
306
346
 
307
347
  &-lists {
308
- padding: 16px 16px 0;
348
+ padding: oSpacingByName('s4') oSpacingByName('s4') 0;
309
349
  @include oTypographySans($scale: 1);
310
350
  &-text {
311
351
  @include oTypographySans($weight: 'semibold');
312
352
  color: oColorsByName('black-80');
313
- margin-bottom: 16px;
353
+ margin-bottom: oSpacingByName('s3');
314
354
  }
315
355
  &-container {
316
356
  margin-top: 0;
317
357
  max-height: 92px;
318
- padding-bottom: 2px;
358
+ padding: 4px 2px;
319
359
  overflow-y: auto;
320
360
  @include oGridRespondTo($from: M) {
321
361
  max-height: 126px;
@@ -323,4 +363,34 @@ $spacing-unit: 20px;
323
363
  }
324
364
  }
325
365
  }
366
+
367
+ .myft-notification {
368
+ background: oColorsByName('white-80');
369
+ box-sizing: border-box;
370
+ display: flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ position: absolute;
374
+ border-radius: 5px;
375
+ font-family: MetricWeb, sans-serif;
376
+ font-size: 18px;
377
+ }
378
+
379
+ .share-nav__vertical {
380
+ .myft-notification {
381
+ top: 175px;
382
+ width: 340px;
383
+ height: 44px;
384
+ left: 50px;
385
+ }
386
+ }
387
+
388
+ .share-nav__horizontal {
389
+ .myft-notification {
390
+ top: -52px;
391
+ width: 340px;
392
+ height: 44px;
393
+ z-index: 10;
394
+ }
395
+ }
326
396
  }
@@ -0,0 +1,5 @@
1
+ export default function stringToHTMLElement (string) {
2
+ const template = document.createElement('template');
3
+ template.innerHTML = string.trim();
4
+ return template.content.firstChild;
5
+ }
@@ -0,0 +1,7 @@
1
+ const MOBILE_BREAKPOINT = 980;
2
+
3
+ export default function isMobile () {
4
+ const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
5
+
6
+ return vw <= MOBILE_BREAKPOINT;
7
+ }
package/myft/ui/lists.js CHANGED
@@ -5,8 +5,10 @@ import * as myFtUiButtonStates from './lib/button-states';
5
5
  import nNotification from '@financial-times/n-notification';
6
6
  import { uuid } from 'n-ui-foundations';
7
7
  import getToken from './lib/get-csrf-token';
8
+ import isMobile from './lib/is-mobile';
8
9
  import oForms from '@financial-times/o-forms';
9
10
  import openSaveArticleToListVariant from './save-article-to-list-variant';
11
+ import stringToHTMLElement from './lib/convert-string-to-html-element';
10
12
 
11
13
  const delegate = new Delegate(document.body);
12
14
  const csrfToken = getToken();
@@ -178,8 +180,13 @@ function showArticleSavedOverlay (contentId) {
178
180
  showListsOverlay('Article saved', `/myft/list?fragment=true&fromArticleSaved=true&contentId=${contentId}`, contentId);
179
181
  }
180
182
 
181
- function showCreateListAndAddArticleOverlay (contentId, name = 'myft-ui-create-list-variant') {
182
- return openSaveArticleToListVariant(name, contentId);
183
+ function showCreateListAndAddArticleOverlay (contentId, config) {
184
+ const options = {
185
+ name: 'myft-ui-create-list-variant',
186
+ ...config
187
+ };
188
+
189
+ return openSaveArticleToListVariant(contentId, options);
183
190
  }
184
191
 
185
192
  function handleArticleSaved (contentId) {
@@ -192,11 +199,11 @@ function handleArticleSaved (contentId) {
192
199
  });
193
200
  }
194
201
 
195
- function openCreateListAndAddArticleOverlay (contentId) {
202
+ function openCreateListAndAddArticleOverlay (contentId, config) {
196
203
  return myFtClient.getAll('created', 'list')
197
204
  .then(createdLists => createdLists.filter(list => !list.isRedirect))
198
205
  .then(() => {
199
- return showCreateListAndAddArticleOverlay(contentId);
206
+ return showCreateListAndAddArticleOverlay(contentId, config);
200
207
  });
201
208
  }
202
209
 
@@ -265,14 +272,39 @@ function initialEventListeners () {
265
272
  // Checks if the createListAndSaveArticle variant is active
266
273
  // and will show the variant overlay if the user has no lists,
267
274
  // otherwise it will show the classic overlay
268
- const createNewListDesign = event.currentTarget.querySelector('[data-myft-ui-save-new="manageArticleLists"]');
269
- if (createNewListDesign) {
270
- return openCreateListAndAddArticleOverlay(contentId);
275
+ const newListDesign = event.currentTarget.querySelector('[data-myft-ui-save-new="manageArticleLists"]');
276
+ if (newListDesign) {
277
+ const configKeys = newListDesign.dataset.myftUiSaveNewConfig.split(',');
278
+ const config = configKeys.reduce((configObj, key) => (key ? { ...configObj, [key]: true} : configObj), {});
279
+
280
+ // Temporary events on the public toggle feature.
281
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
282
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
283
+ detail: {
284
+ category: 'publicToggle',
285
+ action: 'savedArticle',
286
+ article_id: contentId,
287
+ teamName: 'customer-products-us-growth',
288
+ amplitudeExploratory: true
289
+ },
290
+ bubbles: true
291
+ }));
292
+
293
+ return openCreateListAndAddArticleOverlay(contentId, config);
271
294
  }
272
295
 
273
296
  handleArticleSaved(contentId);
274
297
  });
275
298
 
299
+ document.body.addEventListener('myft.user.saved.content.remove', event => {
300
+ const contentId = event.detail.subject;
301
+
302
+ const newListDesign = event.currentTarget.querySelector('[data-myft-ui-save-new="manageArticleLists"]');
303
+ if (newListDesign) {
304
+ return showUnsavedNotification(contentId);
305
+ }
306
+ });
307
+
276
308
  delegate.on('click', '[data-myft-ui="copy-to-list"]', event => {
277
309
  event.preventDefault();
278
310
  showCopyToListOverlay(event.target.getAttribute('data-content-id'), event.target.getAttribute('data-actor-id'));
@@ -285,6 +317,34 @@ function initialEventListeners () {
285
317
  delegate.on('submit', '[data-myft-ui="contained"]', handleRemoveToggleSubmit);
286
318
  }
287
319
 
320
+ function showUnsavedNotification () {
321
+ const parentSelector = isMobile() ? '.o-share--horizontal' : '.o-share--vertical';
322
+ const parentNode = document.querySelector(parentSelector);
323
+
324
+ // We're not supporting multiple notifications for now
325
+ // If a notification is present, we'll silently avoid showing another
326
+ if (document.querySelector('.myft-notification') || !parentNode) {
327
+ return;
328
+ }
329
+
330
+ const content = `
331
+ <p role="alert">Removed from <a href="https://www.ft.com/myft/saved-articles">saved articles</a> in myFT</p>
332
+ `;
333
+
334
+ const contentNode = stringToHTMLElement(content);
335
+
336
+ const container = document.createElement('div');
337
+ container.className = 'myft-notification';
338
+ container.appendChild(contentNode);
339
+
340
+ parentNode.appendChild(container);
341
+
342
+ setTimeout(
343
+ () => parentNode.removeChild(container),
344
+ 5 * 1000
345
+ );
346
+ }
347
+
288
348
  export function init () {
289
349
  initialEventListeners();
290
350
  }
@@ -2,6 +2,8 @@ import Overlay from '@financial-times/o-overlay';
2
2
  import myFtClient from 'next-myft-client';
3
3
  import { uuid } from 'n-ui-foundations';
4
4
  import getToken from './lib/get-csrf-token';
5
+ import isMobile from './lib/is-mobile';
6
+ import stringToHTMLElement from './lib/convert-string-to-html-element';
5
7
 
6
8
  const csrfToken = getToken();
7
9
 
@@ -9,29 +11,26 @@ let lists = [];
9
11
  let haveLoadedLists = false;
10
12
  let createListOverlay;
11
13
 
12
- export default async function openSaveArticleToListVariant (name, contentId) {
13
- function createList (list, cb) {
14
- if(!list) {
15
- if (!lists.length) attachDescription();
16
- return contentElement.addEventListener('click', openFormHandler, { once: true });
14
+ export default async function openSaveArticleToListVariant (contentId, options = {}) {
15
+ const { name, showPublicToggle = false } = options;
16
+
17
+ function createList (newList, cb) {
18
+ if(!newList || !newList.name) {
19
+ return restoreContent();
17
20
  }
18
21
 
19
- myFtClient.add('user', null, 'created', 'list', uuid(), { name: list, token: csrfToken })
22
+ myFtClient.add('user', null, 'created', 'list', uuid(), { name: newList.name, token: csrfToken })
20
23
  .then(detail => {
21
- myFtClient.add('list', detail.subject, 'contained', 'content', contentId, { token: csrfToken }).then((createdList) => {
22
- lists.unshift({ name: list, uuid: createdList.actorId, checked: true });
23
- const listElement = ListsElement(lists, addToList, removeFromList);
24
- const overlayContent = document.querySelector('.o-overlay__content');
25
- overlayContent.insertAdjacentElement('afterbegin', listElement);
24
+ myFtClient.add('list', detail.subject, 'contained', 'content', contentId, { token: csrfToken }).then((data) => {
25
+ const createdList = { name: newList.name, uuid: data.actorId, checked: true, isShareable: !!newList.isShareable };
26
+ lists.unshift(createdList);
26
27
  const announceListContainer = document.querySelector('.myft-ui-create-list-variant-announcement');
27
- announceListContainer.textContent = `${list} created`;
28
- contentElement.addEventListener('click', openFormHandler, { once: true });
29
- cb(contentId, createdList.actorId);
28
+ announceListContainer.textContent = `${newList.name} created`;
29
+ cb(contentId, createdList);
30
30
  });
31
31
  })
32
32
  .catch(() => {
33
- if (!lists.length) attachDescription();
34
- return contentElement.addEventListener('click', openFormHandler, { once: true });
33
+ return restoreContent();
35
34
  });
36
35
  }
37
36
 
@@ -57,6 +56,13 @@ export default async function openSaveArticleToListVariant (name, contentId) {
57
56
  });
58
57
  }
59
58
 
59
+ function restoreContent () {
60
+ if (!lists.length) attachDescription();
61
+ refreshListElement();
62
+ showListElement();
63
+ return restoreFormHandler();
64
+ }
65
+
60
66
  if (!haveLoadedLists) {
61
67
  lists = await getLists(contentId);
62
68
  haveLoadedLists = true;
@@ -69,7 +75,8 @@ export default async function openSaveArticleToListVariant (name, contentId) {
69
75
  }
70
76
 
71
77
  const headingElement = HeadingElement();
72
- let [contentElement, removeDescription, attachDescription] = ContentElement(!lists.length);
78
+ let [contentElement, removeDescription, attachDescription, restoreFormHandler] = ContentElement(!lists.length, openFormHandler);
79
+ const [listElement, refreshListElement, hideListElement, showListElement] = ListsElement(lists, addToList, removeFromList);
73
80
 
74
81
  createListOverlay = new Overlay(name, {
75
82
  html: contentElement,
@@ -90,36 +97,40 @@ export default async function openSaveArticleToListVariant (name, contentId) {
90
97
  }
91
98
  }
92
99
 
100
+ function onFormCancel () {
101
+ showListElement();
102
+ restoreFormHandler();
103
+ }
104
+
105
+ function onFormListCreated () {
106
+ refreshListElement();
107
+ showListElement();
108
+ restoreFormHandler();
109
+ }
110
+
93
111
  function openFormHandler () {
94
- const formElement = FormElement(createList);
112
+ hideListElement();
113
+ const formElement = FormElement(createList, showPublicToggle, attachDescription, onFormListCreated, onFormCancel);
95
114
  const overlayContent = document.querySelector('.o-overlay__content');
96
115
  removeDescription();
97
116
  overlayContent.insertAdjacentElement('beforeend', formElement);
98
117
  formElement.elements[0].focus();
99
118
  }
100
119
 
101
- function getScrollHandler (target) {
102
- return realignOverlay(window.scrollY, target);
103
- }
104
-
105
- function resizeHandler () {
106
- positionOverlay(createListOverlay.wrapper);
107
- }
108
-
109
120
  createListOverlay.open();
110
121
 
111
122
  const scrollHandler = getScrollHandler(createListOverlay.wrapper);
123
+ const resizeHandler = getResizeHandler(createListOverlay.wrapper);
112
124
 
113
125
  createListOverlay.wrapper.addEventListener('oOverlay.ready', (data) => {
114
126
  if (lists.length) {
115
- const listElement = ListsElement(lists, addToList, removeFromList);
116
127
  const overlayContent = document.querySelector('.o-overlay__content');
117
128
  overlayContent.insertAdjacentElement('afterbegin', listElement);
118
129
  }
119
130
 
120
131
  positionOverlay(data.currentTarget);
121
132
 
122
- contentElement.addEventListener('click', openFormHandler, { once: true });
133
+ restoreFormHandler();
123
134
 
124
135
  document.querySelector('.article-content').addEventListener('click', outsideClickHandler);
125
136
 
@@ -137,23 +148,90 @@ export default async function openSaveArticleToListVariant (name, contentId) {
137
148
  });
138
149
  }
139
150
 
140
- function stringToHTMLElement (string) {
141
- const template = document.createElement('template');
142
- template.innerHTML = string.trim();
143
- return template.content.firstChild;
151
+ function showMessageOverlay () {
152
+ function onContinue () {
153
+ messageOverlay.destroy();
154
+ createListOverlay.show();
155
+ triggerAcknowledgeMessageEvent();
156
+ }
157
+
158
+ const messageElement = MessageElement(onContinue);
159
+
160
+ const messageOverlay = new Overlay('myft-ui-create-list-variant-message', {
161
+ html: messageElement,
162
+ modal: false,
163
+ parentnode: isMobile() ? '.o-share--horizontal' : '.o-share--vertical',
164
+ class: 'myft-ui-create-list-variant-message',
165
+ });
166
+
167
+ const scrollHandler = getScrollHandler(messageOverlay.wrapper);
168
+ const resizeHandler = getResizeHandler(messageOverlay.wrapper);
169
+
170
+ messageOverlay.open();
171
+
172
+ messageOverlay.wrapper.addEventListener('oOverlay.ready', (data) => {
173
+ positionOverlay(data.currentTarget);
174
+
175
+ window.addEventListener('scroll', scrollHandler);
176
+
177
+ window.addEventListener('oViewport.resize', resizeHandler);
178
+ });
179
+
180
+ messageOverlay.wrapper.addEventListener('oOverlay.destroy', () => {
181
+ window.removeEventListener('scroll', scrollHandler);
182
+
183
+ window.removeEventListener('oViewport.resize', resizeHandler);
184
+ });
185
+
186
+ return messageOverlay;
187
+ }
188
+
189
+ function getScrollHandler (target) {
190
+ return realignOverlay(window.scrollY, target);
144
191
  }
145
192
 
146
- function FormElement (createList) {
193
+ function getResizeHandler (target) {
194
+ return function resizeHandler () {
195
+ positionOverlay(target);
196
+ };
197
+ }
198
+
199
+ function FormElement (createList, showPublicToggle, attachDescription, onListCreated, onCancel) {
147
200
  const formString = `
148
201
  <form class="myft-ui-create-list-variant-form">
149
- <label class="o-forms-field">
202
+ <label class="myft-ui-create-list-variant-form-name o-forms-field">
150
203
  <span class="o-forms-input o-forms-input--text">
151
- <input type="text" name="list-name" aria-label="List name">
204
+ <input class="myft-ui-create-list-variant-text" type="text" name="list-name" aria-label="List name">
152
205
  </span>
153
206
  </label>
154
- <button class="o-buttons o-buttons--secondary" type="submit">
155
- Save
156
- </button>
207
+
208
+ ${showPublicToggle ?
209
+ `<div class="myft-ui-create-list-variant-form-public o-forms-field" role="group">
210
+ <span class="o-forms-input o-forms-input--toggle">
211
+ <label>
212
+ <input class="myft-ui-create-list-variant-form-toggle" type="checkbox" name="is-shareable" value="public" checked data-trackable="private-link" text="private">
213
+ <span class="myft-ui-create-list-variant-form-toggle-label o-forms-input__label">
214
+ <span class="o-forms-input__label__main">
215
+ Public
216
+ </span>
217
+ <span id="myft-ui-create-list-variant-form-public-description" class="o-forms-input__label__prompt">
218
+ Your profession & list will be visible to others
219
+ </span>
220
+ </span>
221
+ </label>
222
+ </span>
223
+ </div>` :
224
+ ''
225
+ }
226
+
227
+ <div class="myft-ui-create-list-variant-form-buttons">
228
+ <button class="o-buttons o-buttons--primary o-buttons--inverse o-buttons--big" type="button" data-trackable="cancel-link" text="cancel">
229
+ Cancel
230
+ </button>
231
+ <button class="o-buttons o-buttons--big o-buttons--secondary" type="submit">
232
+ Add
233
+ </button>
234
+ </div>
157
235
  </form>
158
236
  `;
159
237
 
@@ -163,26 +241,63 @@ function FormElement (createList) {
163
241
  event.preventDefault();
164
242
  event.stopPropagation();
165
243
  const inputListName = formElement.querySelector('input[name="list-name"]');
166
- createList(inputListName.value, ((contentId, listId) => {
167
- triggerCreateListEvent(contentId, listId);
168
- triggerAddToListEvent(contentId, listId);
244
+ const inputIsShareable = formElement.querySelector('input[name="is-shareable"]');
245
+
246
+ const newList = {
247
+ name: inputListName.value,
248
+ isShareable: inputIsShareable ? inputIsShareable.checked : false
249
+ };
250
+
251
+ createList(newList, ((contentId, createdList) => {
252
+ triggerCreateListEvent(contentId, createdList.uuid);
253
+ triggerAddToListEvent(contentId, createdList.uuid);
169
254
  positionOverlay(createListOverlay.wrapper);
255
+ triggerCancelEvent();
256
+
257
+ if (createdList.isShareable) {
258
+ createListOverlay.close();
259
+ showMessageOverlay();
260
+ }
261
+
262
+ onListCreated();
170
263
  }));
171
- inputListName.value = '';
172
264
  formElement.remove();
173
265
  }
174
266
 
267
+ function handleCancelClick (event) {
268
+ event.preventDefault();
269
+ event.stopPropagation();
270
+ formElement.remove();
271
+ if (!lists.length) attachDescription();
272
+ onCancel();
273
+ }
274
+
175
275
  formElement.querySelector('button[type="submit"]').addEventListener('click', handleSubmit);
276
+ formElement.querySelector('button[type="button"]').addEventListener('click', handleCancelClick);
277
+
278
+ if (showPublicToggle) {
279
+ addPublicToggleListener(formElement);
280
+ }
176
281
 
177
282
  return formElement;
178
283
  }
179
284
 
180
- function ContentElement (hasDescription) {
285
+ function addPublicToggleListener (formElement) {
286
+ function onPublicToggleClick (event) {
287
+ event.target.setAttribute('data-trackable', event.target.checked ? 'private-link' : 'public-link');
288
+ event.target.setAttribute('text', event.target.checked ? 'private' : 'public');
289
+ triggerPublicToggleEvent(event.target.checked);
290
+ }
291
+
292
+ formElement.querySelector('input[name="is-shareable"]').addEventListener('click', onPublicToggleClick);
293
+ }
294
+
295
+ function ContentElement (hasDescription, onClick) {
181
296
  const description = '<p class="myft-ui-create-list-variant-add-description">Lists are a simple way to curate your content</p>';
182
297
 
183
298
  const content = `
184
299
  <div class="myft-ui-create-list-variant-footer">
185
- <button class="myft-ui-create-list-variant-add" data-trackable="add-to-new-list" text="Add to a new list">Add to a new list</button>
300
+ <button class="myft-ui-create-list-variant-add" data-trackable="add-to-new-list" text="add to new list">Add to a new list</button>
186
301
  ${hasDescription ? `
187
302
  ${description}
188
303
  ` : ''}
@@ -191,6 +306,8 @@ function ContentElement (hasDescription) {
191
306
 
192
307
  const contentElement = stringToHTMLElement(content);
193
308
 
309
+ contentElement.querySelector('.myft-ui-create-list-variant-add').addEventListener('click', triggerAddToNewListEvent);
310
+
194
311
  function removeDescription () {
195
312
  const descriptionElement = contentElement.querySelector('.myft-ui-create-list-variant-add-description');
196
313
  if (descriptionElement) {
@@ -203,7 +320,11 @@ function ContentElement (hasDescription) {
203
320
  contentElement.insertAdjacentElement('beforeend', descriptionElement);
204
321
  }
205
322
 
206
- return [contentElement, removeDescription, attachDescription];
323
+ function restoreFormHandler () {
324
+ return contentElement.addEventListener('click', onClick, { once: true });
325
+ }
326
+
327
+ return [contentElement, removeDescription, attachDescription, restoreFormHandler];
207
328
  }
208
329
 
209
330
  function HeadingElement () {
@@ -224,7 +345,7 @@ function ListsElement (lists, addToList, removeFromList) {
224
345
 
225
346
  const listsTemplate = `
226
347
  <div class="myft-ui-create-list-variant-lists o-forms-field o-forms-field--optional" role="group">
227
- <span class="myft-ui-create-list-variant-lists-text">Add to a list</span>
348
+ <span class="myft-ui-create-list-variant-lists-text">Add to list</span>
228
349
  <span class="myft-ui-create-list-variant-lists-container o-forms-input o-forms-input--checkbox">
229
350
  </span>
230
351
  </div>
@@ -233,9 +354,21 @@ function ListsElement (lists, addToList, removeFromList) {
233
354
 
234
355
  const listsElementContainer = listsElement.querySelector('.myft-ui-create-list-variant-lists-container');
235
356
 
236
- lists.map(list => listsElementContainer.insertAdjacentElement('beforeend', listCheckboxElement(list)));
357
+ function refresh () {
358
+ listsElementContainer.replaceChildren(...lists.map(list => listCheckboxElement(list)));
359
+ }
360
+
361
+ function hide () {
362
+ listsElement.style.display = 'none';
363
+ }
237
364
 
238
- return listsElement;
365
+ function show () {
366
+ listsElement.style.display = 'flex';
367
+ }
368
+
369
+ refresh();
370
+
371
+ return [listsElement, refresh, hide, show];
239
372
  }
240
373
 
241
374
  function ListCheckboxElement (addToList, removeFromList) {
@@ -275,6 +408,28 @@ function ListCheckboxElement (addToList, removeFromList) {
275
408
  };
276
409
  }
277
410
 
411
+ function MessageElement (onContinue) {
412
+ const message = `
413
+ <div class="myft-ui-create-list-variant-message-content" >
414
+ <div class="myft-ui-create-list-variant-message-text" aria-live="polite">
415
+ <h3>Thank you for your interest in making a public list</h3>
416
+ <p>We're currently testing this feature. For now, your list remains private and isn't visible to others.</p>
417
+ </div>
418
+ <div class="myft-ui-create-list-variant-message-buttons">
419
+ <button class="o-buttons o-buttons--big o-buttons--secondary" data-trackable="continue-link" text="continue">
420
+ Continue
421
+ </button>
422
+ </div>
423
+ </div>
424
+ `;
425
+
426
+ const messageElement = stringToHTMLElement(message);
427
+
428
+ messageElement.querySelector('button').addEventListener('click', onContinue);
429
+
430
+ return messageElement;
431
+ }
432
+
278
433
  function realignOverlay (originalScrollPosition, target) {
279
434
  return function () {
280
435
  const currentScrollPosition = window.scrollY;
@@ -294,29 +449,22 @@ function realignOverlay (originalScrollPosition, target) {
294
449
  function positionOverlay (target) {
295
450
  target.style['min-width'] = '340px';
296
451
  target.style['width'] = '100%';
297
- target.style['margin-top'] = '-50px';
452
+ target.style['margin-top'] = 0;
298
453
  target.style['left'] = 0;
454
+ target.style['top'] = 0;
299
455
 
300
456
  if (isMobile()) {
301
457
  const shareNavComponent = document.querySelector('.share-nav__horizontal');
302
458
  const topHalfOffset = target.clientHeight + 10;
303
459
  target.style['position'] = 'absolute';
304
460
  target.style['margin-left'] = 0;
305
- target.style['margin-top'] = 0;
306
461
  target.style['top'] = calculateLargerScreenHalf(shareNavComponent) === 'ABOVE' ? `-${topHalfOffset}px` : '50px';
307
462
  } else {
308
463
  target.style['position'] = 'absolute';
309
464
  target.style['margin-left'] = '45px';
310
- target.style['top'] = '220px';
311
465
  }
312
466
  }
313
467
 
314
- function isMobile () {
315
- const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
316
-
317
- return vw <= 980;
318
- }
319
-
320
468
  function calculateLargerScreenHalf (target) {
321
469
  if (!target) {
322
470
  return 'BELOW';
@@ -335,7 +483,7 @@ async function getLists (contentId) {
335
483
  return myFtClient.getListsContent()
336
484
  .then(results => results.items.map(list => {
337
485
  const isChecked = Array.isArray(list.content) && list.content.some(content => content.uuid === contentId);
338
- return { name: list.name, uuid: list.uuid, checked: isChecked, content: list.content };
486
+ return { name: list.name, uuid: list.uuid, checked: isChecked, content: list.content, isShareable: false };
339
487
  }));
340
488
  }
341
489
 
@@ -380,3 +528,59 @@ function triggerCreateListEvent (contentId, listId) {
380
528
  bubbles: true
381
529
  }));
382
530
  }
531
+
532
+ // Temporary event on the public toggle feature.
533
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
534
+ function triggerPublicToggleEvent (isPublic) {
535
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
536
+ detail: {
537
+ category: 'publicToggle',
538
+ action: `${isPublic ? 'setPublic' : 'setPrivate'}`,
539
+ teamName: 'customer-products-us-growth',
540
+ amplitudeExploratory: true
541
+ },
542
+ bubbles: true
543
+ }));
544
+ }
545
+
546
+ // Temporary event on the public toggle feature.
547
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
548
+ function triggerAddToNewListEvent () {
549
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
550
+ detail: {
551
+ category: 'publicToggle',
552
+ action: 'addToNewList',
553
+ teamName: 'customer-products-us-growth',
554
+ amplitudeExploratory: true
555
+ },
556
+ bubbles: true
557
+ }));
558
+ }
559
+
560
+ // Temporary event on the public toggle feature.
561
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
562
+ function triggerAcknowledgeMessageEvent () {
563
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
564
+ detail: {
565
+ category: 'publicToggle',
566
+ action: 'acknowledgeMessage',
567
+ teamName: 'customer-products-us-growth',
568
+ amplitudeExploratory: true
569
+ },
570
+ bubbles: true
571
+ }));
572
+ }
573
+
574
+ // Temporary event on the public toggle feature.
575
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
576
+ function triggerCancelEvent () {
577
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
578
+ detail: {
579
+ category: 'publicToggle',
580
+ action: 'cancel ',
581
+ teamName: 'customer-products-us-growth',
582
+ amplitudeExploratory: true
583
+ },
584
+ bubbles: true
585
+ }));
586
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/n-myft-ui",
3
- "version": "28.2.0",
3
+ "version": "28.2.3",
4
4
  "description": "Client side component for interaction with myft",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -65,6 +65,7 @@
65
65
  "karma-sinon": "^1.0.5",
66
66
  "karma-sinon-chai": "2.0.2",
67
67
  "karma-sourcemap-loader": "^0.3.7",
68
+ "karma-viewport": "^1.0.9",
68
69
  "karma-webpack": "^4.0.2",
69
70
  "lintspaces-cli": "^0.7.0",
70
71
  "lolex": "5.1.1",
@@ -0,0 +1,20 @@
1
+ const expect = require('chai').expect;
2
+ const isMobile = require('../../myft/ui/lib/is-mobile');
3
+
4
+ /* global viewport */
5
+
6
+ describe('IsMobile', function () {
7
+ afterEach(() => {
8
+ viewport.reset();
9
+ });
10
+
11
+ it('detects a desktop device', () => {
12
+ viewport.set(981, 800);
13
+ expect(isMobile()).to.be.false;
14
+ });
15
+
16
+ it('detects a mobile device', () => {
17
+ viewport.set(979, 800);
18
+ expect(isMobile()).to.be.true;
19
+ });
20
+ });