@financial-times/n-myft-ui 27.1.0 → 27.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,7 +3,8 @@
3
3
  data-content-id="{{contentId}}"
4
4
  data-myft-ui="saved"
5
5
  action="/myft/save/{{contentId}}"
6
- data-js-action="/__myft/api/core/saved/content/{{contentId}}?method=put">
6
+ data-js-action="/__myft/api/core/saved/content/{{contentId}}?method=put"
7
+ {{#ifEquals @root.flags.professorLists 'variant'}}data-myft-ui-variant="createListAndSaveArticleVariant"{{/ifEquals}}>
7
8
  {{> n-myft-ui/components/csrf-token/input}}
8
9
  <div
9
10
  class="n-myft-ui__announcement o-normalise-visually-hidden"
package/myft/main.scss CHANGED
@@ -173,3 +173,148 @@ $spacing-unit: 20px;
173
173
  }
174
174
 
175
175
  }
176
+
177
+ .share-nav {
178
+ &.data-overlap-initialised {
179
+ .o-overlay {
180
+ transition: opacity 0.15s ease-in;
181
+ opacity: 0;
182
+ z-index: -1;
183
+ }
184
+ }
185
+
186
+ .myft-ui-create-list-variant {
187
+ border-radius: 10px;
188
+ border: 1px solid oColorsByName('black-5');
189
+ background: oColorsByName('white-80');
190
+
191
+ .o-overlay__heading {
192
+ border-radius: 10px 10px 0 0;
193
+ background: oColorsByName('white-60');
194
+ @include oTypographySans($scale: 2);
195
+ color: oColorsByName('black-80');
196
+ }
197
+
198
+ .o-overlay__content {
199
+ @include oTypographySans($scale: 0);
200
+ color: oColorsByName('black-80');
201
+ padding: 0;
202
+ }
203
+
204
+ .o-overlay__title {
205
+ margin: 8px 14px 0 8px;
206
+ }
207
+
208
+ &-container {
209
+ display: block;
210
+ width: 340px;
211
+ top: 115.5px;
212
+ left: 50px;
213
+ }
214
+
215
+ &-add {
216
+ border: 0;
217
+ background: none;
218
+ @include oTypographySans($scale: 1, $weight: 'semibold');
219
+ color: oColorsByName('black-80');
220
+
221
+ padding-left: 0;
222
+ margin-left: -8px;
223
+
224
+ &:hover {
225
+ text-decoration: underline;
226
+ }
227
+
228
+ &::before {
229
+ content: '';
230
+ @include oIconsContent(
231
+ 'plus',
232
+ oColorsByName('black-80'),
233
+ 28,
234
+ $iconset-version: 1
235
+ );
236
+ vertical-align: middle;
237
+ margin-top: -2px;
238
+ }
239
+ }
240
+
241
+ &-add-description {
242
+ margin: 4px 0;
243
+ }
244
+
245
+ &-heading {
246
+ &::before {
247
+ content: '';
248
+ @include oIconsContent(
249
+ 'tick',
250
+ oColorsByName('teal'),
251
+ 32,
252
+ $iconset-version: 1
253
+ );
254
+ vertical-align: middle;
255
+ margin-top: -2px;
256
+ }
257
+ }
258
+
259
+ &-footer {
260
+ border-top: 1px solid oColorsByName('black-5');
261
+ padding: 16px;
262
+ }
263
+
264
+ &-icon {
265
+ &::before {
266
+ content: "";
267
+ display: inline-block;
268
+ background-repeat: no-repeat;
269
+ background-size: contain;
270
+ background-position: 50%;
271
+ background-color: transparent;
272
+ background-image: url(https://www.ft.com/__origami/service/image/v2/images/raw/ftlogo-v1:brand-myft?source=next-article);
273
+ width: 42px;
274
+ height: 42px;
275
+ vertical-align: middle;
276
+ margin-top: -2px;
277
+ }
278
+
279
+ &-visually-hidden {
280
+ clip: rect(0 0 0 0);
281
+ clip-path: inset(50%);
282
+ height: 1px;
283
+ overflow: hidden;
284
+ position: absolute;
285
+ white-space: nowrap;
286
+ width: 1px;
287
+ }
288
+ }
289
+
290
+ &-form {
291
+ display: flex;
292
+ width: calc(100% - 32px);
293
+ justify-content: space-between;
294
+ height: 40px;
295
+ gap: 8px;
296
+ padding: 0 16px 16px;
297
+
298
+ & > * {
299
+ flex: 1 1 auto;
300
+ }
301
+
302
+ .o-forms-input {
303
+ margin-top: 0;
304
+ }
305
+ }
306
+
307
+ &-lists {
308
+ padding: 16px 16px 0;
309
+ @include oTypographySans($scale: 1);
310
+ &-text {
311
+ @include oTypographySans($weight: 'semibold');
312
+ color: oColorsByName('black-80');
313
+ margin-bottom: 16px;
314
+ }
315
+ &-container {
316
+ margin-top: 0;
317
+ }
318
+ }
319
+ }
320
+ }
package/myft/ui/lists.js CHANGED
@@ -6,6 +6,7 @@ import nNotification from 'n-notification';
6
6
  import { uuid } from 'n-ui-foundations';
7
7
  import getToken from './lib/get-csrf-token';
8
8
  import oForms from 'o-forms';
9
+ import openSaveArticleToListVariant from './save-article-to-list-variant';
9
10
 
10
11
  const delegate = new Delegate(document.body);
11
12
  const csrfToken = getToken();
@@ -161,6 +162,10 @@ function showArticleSavedOverlay (contentId) {
161
162
  showListsOverlay('Article saved', `/myft/list?fragment=true&fromArticleSaved=true&contentId=${contentId}`, contentId);
162
163
  }
163
164
 
165
+ function showCreateListAndAddArticleOverlay (contentId, name = 'myft-ui-create-list-variant') {
166
+ return openSaveArticleToListVariant(name, contentId);
167
+ }
168
+
164
169
  function handleArticleSaved (contentId) {
165
170
  return myFtClient.getAll('created', 'list')
166
171
  .then(createdLists => createdLists.filter(list => !list.isRedirect))
@@ -171,10 +176,27 @@ function handleArticleSaved (contentId) {
171
176
  });
172
177
  }
173
178
 
179
+ function openCreateListAndAddArticleOverlay (contentId) {
180
+ return myFtClient.getAll('created', 'list')
181
+ .then(createdLists => createdLists.filter(list => !list.isRedirect))
182
+ .then(createdLists => {
183
+ return !createdLists.length ? showCreateListAndAddArticleOverlay(contentId) : showArticleSavedOverlay(contentId);
184
+ });
185
+ }
186
+
174
187
  function initialEventListeners () {
175
188
 
176
189
  document.body.addEventListener('myft.user.saved.content.add', event => {
177
190
  const contentId = event.detail.subject;
191
+
192
+ // Checks if the createListAndSaveArticle variant is active
193
+ // and will show the variant overlay if the user has no lists,
194
+ // otherwise it will show the classic overlay
195
+ const createListVariant = event.currentTarget.querySelector('[data-myft-ui-variant="createListAndSaveArticleVariant"]');
196
+ if (createListVariant) {
197
+ return openCreateListAndAddArticleOverlay(contentId);
198
+ }
199
+
178
200
  handleArticleSaved(contentId);
179
201
  });
180
202
 
@@ -0,0 +1,376 @@
1
+ import Overlay from 'o-overlay';
2
+ import myFtClient from 'next-myft-client';
3
+ import { uuid } from 'n-ui-foundations';
4
+ import getToken from './lib/get-csrf-token';
5
+ import oTooltip from 'o-tooltip';
6
+
7
+ const csrfToken = getToken();
8
+
9
+ let lists;
10
+
11
+ export default async function showSaveArticleToListVariant (name, contentId) {
12
+ try {
13
+ await openSaveArticleToListVariant (name, contentId);
14
+ } catch(error) {
15
+ handleError(error);
16
+ }
17
+ }
18
+
19
+ async function openSaveArticleToListVariant (name, contentId) {
20
+ function createList (list) {
21
+ if(!list) {
22
+ return;
23
+ }
24
+
25
+ myFtClient.add('user', null, 'created', 'list', uuid(), { name: list, token: csrfToken })
26
+ .then(detail => {
27
+ myFtClient.add('list', detail.subject, 'contained', 'content', contentId, { token: csrfToken }).then((createdList) => {
28
+ lists.push({ name: list, uuid: createdList.actorId, checked: true });
29
+ const listElement = ListsElement(lists, addToList, removeFromList);
30
+ const overlayContent = document.querySelector('.o-overlay__content');
31
+ overlayContent.insertAdjacentElement('afterbegin', listElement);
32
+ const announceListContainer = document.querySelector('.myft-ui-create-list-variant-announcement');
33
+ announceListContainer.textContent = `${list} created`;
34
+ triggerCreateListEvent(contentId);
35
+ });
36
+ });
37
+ }
38
+
39
+ function addToList (list) {
40
+ if(!list) {
41
+ return;
42
+ }
43
+
44
+ myFtClient.add('list', list.uuid, 'contained', 'content', contentId, { token: csrfToken }).then(() => {
45
+ const indexToUpdate = lists.indexOf(list);
46
+ lists[indexToUpdate] = { ...lists[indexToUpdate], checked: true };
47
+ const listElement = ListsElement(lists, addToList, removeFromList);
48
+ const overlayContent = document.querySelector('.o-overlay__content');
49
+ overlayContent.insertAdjacentElement('afterbegin', listElement);
50
+ triggerAddToListEvent(contentId);
51
+ });
52
+ }
53
+
54
+ function removeFromList (list) {
55
+ if(!list) {
56
+ return;
57
+ }
58
+
59
+ myFtClient.remove('list', list.uuid, 'contained', 'content', contentId, { token: csrfToken }).then(() => {
60
+ const indexToUpdate = lists.indexOf(list);
61
+ lists[indexToUpdate] = { ...lists[indexToUpdate], checked: false };
62
+ const listElement = ListsElement(lists, addToList, removeFromList);
63
+ const overlayContent = document.querySelector('.o-overlay__content');
64
+ overlayContent.insertAdjacentElement('afterbegin', listElement);
65
+ triggerRemoveFromListEvent(contentId);
66
+ });
67
+ }
68
+
69
+ if (!lists) {
70
+ lists = await getLists(contentId);
71
+ }
72
+
73
+ const overlays = Overlay.getOverlays();
74
+ const existingOverlay = overlays[name];
75
+ if (existingOverlay) {
76
+ existingOverlay.destroy();
77
+ }
78
+
79
+ const contentElement = ContentElement();
80
+ const headingElement = HeadingElement();
81
+
82
+ const createListOverlay = new Overlay(name, {
83
+ html: contentElement,
84
+ heading: { title: headingElement.outerHTML },
85
+ modal: false,
86
+ parentnode: isMobile() ? '.o-share--horizontal' : '.o-share--vertical',
87
+ class: 'myft-ui-create-list-variant',
88
+ });
89
+
90
+ const realignListener = realignOverlay(window.scrollY);
91
+
92
+ function outsideClickHandler (e) {
93
+ try {
94
+ const overlayContent = document.querySelector('.o-overlay__content');
95
+ if(!overlayContent || !overlayContent.contains(e.target)) {
96
+ createListOverlay.close();
97
+ }
98
+ } catch(error) {
99
+ handleError(error);
100
+ }
101
+ }
102
+
103
+ function openFormHandler () {
104
+ try {
105
+ const formElement = FormElement(createList);
106
+ const overlayContent = document.querySelector('.o-overlay__content');
107
+ overlayContent.insertAdjacentElement('beforeend', formElement);
108
+ formElement.elements[0].focus();
109
+ } catch(error) {
110
+ handleError(error);
111
+ }
112
+ }
113
+
114
+ createListOverlay.open();
115
+ createListOverlay.wrapper.addEventListener('oOverlay.ready', (data) => {
116
+ realignListener(data.currentTarget);
117
+
118
+ if (lists && lists.length) {
119
+ const listElement = ListsElement(lists, addToList, removeFromList);
120
+ const overlayContent = document.querySelector('.o-overlay__content');
121
+ overlayContent.insertAdjacentElement('afterbegin', listElement);
122
+ }
123
+
124
+ contentElement.addEventListener('click', openFormHandler);
125
+
126
+ document.querySelector('.article-content').addEventListener('click', outsideClickHandler);
127
+ });
128
+
129
+ createListOverlay.wrapper.addEventListener('oOverlay.destroy', () => {
130
+ const tooltipTemplate = document.createElement('div');
131
+ const opts = {
132
+ target: 'o-header-top-link-myft',
133
+ content: 'Go to saved articles in myFT to find your lists',
134
+ showOnConstruction: true,
135
+ closeAfter: 5,
136
+ position: 'below'
137
+ };
138
+
139
+ new oTooltip(tooltipTemplate, opts);
140
+
141
+ contentElement.removeEventListener('click', openFormHandler);
142
+
143
+ document.querySelector('.article-content').removeEventListener('click', outsideClickHandler);
144
+ });
145
+
146
+ window.addEventListener('scroll', function () {
147
+ realignListener(createListOverlay.wrapper, window.scrollY);
148
+ });
149
+
150
+ window.addEventListener('oViewport.resize', () => {
151
+ realignListener(createListOverlay.wrapper);
152
+ });
153
+ }
154
+
155
+ function stringToHTMLElement (string) {
156
+ const template = document.createElement('template');
157
+ template.innerHTML = string.trim();
158
+ return template.content.firstChild;
159
+ }
160
+
161
+ function FormElement (createList) {
162
+ const formString = `
163
+ <form class="myft-ui-create-list-variant-form">
164
+ <label class="o-forms-field">
165
+ <span class="o-forms-input o-forms-input--text">
166
+ <input type="text" name="list-name" aria-label="List name">
167
+ </span>
168
+ </label>
169
+ <button class="o-buttons o-buttons--secondary" type="submit">
170
+ Save
171
+ </button>
172
+ </form>
173
+ `;
174
+
175
+ const formElement = stringToHTMLElement(formString);
176
+
177
+ function handleSubmit (event) {
178
+ try {
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+ const inputListName = formElement.querySelector('input[name="list-name"]');
182
+ createList(inputListName.value);
183
+ inputListName.value = '';
184
+ formElement.remove();
185
+ } catch(error) {
186
+ handleError(error);
187
+ }
188
+ }
189
+
190
+ formElement.querySelector('button[type="submit"]').addEventListener('click', handleSubmit);
191
+
192
+ return formElement;
193
+ }
194
+
195
+ function ContentElement () {
196
+ let content = `
197
+ <div class="myft-ui-create-list-variant-footer">
198
+ <button class="myft-ui-create-list-variant-add">Add to a new list</button>
199
+ ${!lists.length ? '<p class="myft-ui-create-list-variant-add-description">Lists are a simple way to curate your content</p>' : ''}
200
+ </div>
201
+ `;
202
+
203
+ return stringToHTMLElement(content);
204
+ }
205
+
206
+ function HeadingElement () {
207
+ const heading = `
208
+ <span class="myft-ui-create-list-variant-heading">Added to <a href="https://www.ft.com/myft/saved-articles">saved articles</a> in <span class="myft-ui-create-list-variant-icon"><span class="myft-ui-create-list-variant-icon-visually-hidden">my FT</span></span></span>
209
+ `;
210
+
211
+ return stringToHTMLElement(heading);
212
+ }
213
+
214
+ function ListsElement (lists, addToList, removeFromList) {
215
+ const currentList = document.querySelector('.myft-ui-create-list-variant-lists');
216
+ if (currentList) {
217
+ currentList.remove();
218
+ }
219
+
220
+ const listCheckboxElement = ListCheckboxElement(addToList, removeFromList);
221
+
222
+ const listsTemplate = `
223
+ <div class="myft-ui-create-list-variant-lists o-forms-field o-forms-field--optional" role="group">
224
+ <span class="myft-ui-create-list-variant-lists-text">Add to a list</span>
225
+ <span class="myft-ui-create-list-variant-lists-container o-forms-input o-forms-input--checkbox">
226
+ </span>
227
+ </div>
228
+ `;
229
+ const listsElement = stringToHTMLElement(listsTemplate);
230
+
231
+ const listsElementContainer = listsElement.querySelector('.myft-ui-create-list-variant-lists-container');
232
+
233
+ lists.map(list => listsElementContainer.insertAdjacentElement('beforeend', listCheckboxElement(list)));
234
+
235
+ return listsElement;
236
+ }
237
+
238
+ function ListCheckboxElement (addToList, removeFromList) {
239
+ return function (list) {
240
+ const listCheckbox = `<label>
241
+ <input type="checkbox" name="default" value="${list.name}" ${list.checked ? 'checked' : ''}>
242
+ <span class="o-forms-input__label">
243
+ <span class="o-normalise-visually-hidden">
244
+ ${list.checked ? 'Remove article from ' : 'Add article to ' }
245
+ </span>
246
+ ${list.name}
247
+ </span>
248
+ </label>
249
+ `;
250
+
251
+ const listCheckboxElement = stringToHTMLElement(listCheckbox);
252
+
253
+ const [ input ] = listCheckboxElement.children;
254
+
255
+ function handleCheck (event) {
256
+ event.preventDefault();
257
+ return event.target.checked ? addToList(list) : removeFromList(list);
258
+ }
259
+
260
+ input.addEventListener('click', handleCheck);
261
+
262
+ return listCheckboxElement;
263
+ };
264
+ }
265
+
266
+ function realignOverlay (originalScrollPosition) {
267
+ return function (target, currentScrollPosition) {
268
+ try {
269
+ if(currentScrollPosition && Math.abs(currentScrollPosition - originalScrollPosition) < 120) {
270
+ return;
271
+ }
272
+
273
+ originalScrollPosition = currentScrollPosition;
274
+
275
+ target.style['min-width'] = '340px';
276
+ target.style['width'] = '100%';
277
+ target.style['margin-top'] = '-50px';
278
+ target.style['left'] = 0;
279
+
280
+ if (isMobile()) {
281
+ target.style['position'] = 'absolute';
282
+ target.style['margin-left'] = 0;
283
+ target.style['margin-top'] = 0;
284
+ target.style['top'] = calculateLargerScreenHalf(target) === 'ABOVE' ? '-120px' : '50px';
285
+ } else {
286
+ target.style['position'] = 'absolute';
287
+ target.style['margin-left'] = '45px';
288
+ target.style['top'] = '220px';
289
+ }
290
+ } catch (error) {
291
+ handleError(error);
292
+ }
293
+ };
294
+ }
295
+
296
+ function isMobile () {
297
+ const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
298
+
299
+ return vw <= 980;
300
+ }
301
+
302
+ function calculateLargerScreenHalf (target) {
303
+ const vh = Math.min(document.documentElement.clientHeight || 0, window.innerHeight || 0);
304
+
305
+ const targetBox = target.getBoundingClientRect();
306
+ const spaceAbove = targetBox.top;
307
+ const spaceBelow = vh - targetBox.bottom;
308
+
309
+ return spaceBelow < spaceAbove ? 'ABOVE' : 'BELOW';
310
+ }
311
+
312
+ async function getLists () {
313
+ return myFtClient.getAll('created', 'list')
314
+ .then(lists => lists.filter(list => !list.isRedirect))
315
+ .then(lists => {
316
+ return lists.map(list => ({ name: list.name, uuid: list.uuid, checked: false }));
317
+ });
318
+ }
319
+
320
+ function triggerAddToListEvent (contentId) {
321
+ return document.body.dispatchEvent(new CustomEvent('oTracking.event', {
322
+ detail: {
323
+ category: 'professorLists',
324
+ action: 'add-to-list',
325
+ article_id: contentId,
326
+ teamName: 'customer-products-us-growth',
327
+ amplitudeExploratory: true
328
+ },
329
+ bubbles: true
330
+ }));
331
+ }
332
+
333
+ function triggerRemoveFromListEvent (contentId) {
334
+ return document.body.dispatchEvent(new CustomEvent('oTracking.event', {
335
+ detail: {
336
+ category: 'professorLists',
337
+ action: 'remove-from-list',
338
+ article_id: contentId,
339
+ teamName: 'customer-products-us-growth',
340
+ amplitudeExploratory: true
341
+ },
342
+ bubbles: true
343
+ }));
344
+ }
345
+
346
+ function triggerCreateListEvent (contentId) {
347
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
348
+ detail: {
349
+ category: 'professorLists',
350
+ action: 'create-list',
351
+ article_id: contentId,
352
+ teamName: 'customer-products-us-growth',
353
+ amplitudeExploratory: true
354
+ },
355
+ bubbles: true
356
+ }));
357
+
358
+ return document.body.dispatchEvent(new CustomEvent('oTracking.event', {
359
+ detail: {
360
+ category: 'myFT',
361
+ action: 'create-list-success',
362
+ article_id: contentId
363
+ },
364
+ bubbles: true
365
+ }));
366
+ }
367
+
368
+ function handleError (error) {
369
+ document.body.dispatchEvent(new CustomEvent('oErrors.log', {
370
+ bubbles: true,
371
+ detail: {
372
+ error,
373
+ info: { component: 'professorLists' },
374
+ }
375
+ }));
376
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/n-myft-ui",
3
- "version": "27.1.0",
3
+ "version": "27.2.0",
4
4
  "description": "Client side component for interaction with myft",
5
5
  "main": "server.js",
6
6
  "scripts": {