@financial-times/x-teaser 17.0.5 → 18.1.0

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.
package/Props.d.ts CHANGED
@@ -31,6 +31,7 @@ export interface Features {
31
31
  showTitle?: boolean
32
32
  showStandfirst?: boolean
33
33
  showPremiumLabel?: boolean
34
+ showScoopLabel?: boolean
34
35
  showStatus?: boolean
35
36
  showImage?: boolean
36
37
  showHeadshot?: boolean
@@ -40,6 +41,10 @@ export interface Features {
40
41
  showPromotionalContent?: boolean
41
42
  }
42
43
 
44
+ export interface SpecialStylings {
45
+ allowLiveTeaserStyling?: boolean
46
+ }
47
+
43
48
  export interface General {
44
49
  id: string
45
50
  url?: string
@@ -0,0 +1,312 @@
1
+ const { h } = require('@financial-times/x-engine')
2
+ const { mount } = require('@financial-times/x-test-utils/enzyme')
3
+
4
+ import * as LiveBlogStatus from '../src/LiveBlogStatus'
5
+ import * as ScoopLabel from '../src/ScoopLabel'
6
+ import * as PremiumLabel from '../src/PremiumLabel'
7
+ import * as AlwaysShowTimestamp from '../src/AlwaysShowTimestamp'
8
+ import * as RelativeTime from '../src/RelativeTime'
9
+ import * as TimeStamp from '../src/TimeStamp'
10
+
11
+ const LiveBlogStatusSpy = jest
12
+ .spyOn(LiveBlogStatus, 'default')
13
+ .mockReturnValue(<div className="live-blog-status">LiveBlogStatus</div>)
14
+ const ScoopLabelSpy = jest
15
+ .spyOn(ScoopLabel, 'default')
16
+ .mockReturnValue(<div className="scoop-label">ScoopLabel</div>)
17
+ const PremiumLabelSpy = jest
18
+ .spyOn(PremiumLabel, 'default')
19
+ .mockReturnValue(<div className="premium-label">PremiumLabel</div>)
20
+ const AlwaysShowTimestampSpy = jest
21
+ .spyOn(AlwaysShowTimestamp, 'default')
22
+ .mockReturnValue(<div className="always-show-timestamp">AlwaysShowTimestamp</div>)
23
+ const RelativeTimeSpy = jest
24
+ .spyOn(RelativeTime, 'default')
25
+ .mockReturnValue(<div className="relative-time">RelativeTimeSpy</div>)
26
+ const TimeStampSpy = jest
27
+ .spyOn(TimeStamp, 'default')
28
+ .mockReturnValue(<div className="timestamp">TimeStamp</div>)
29
+
30
+ import Status from '../src/Status'
31
+
32
+ describe('Status - Display Logic', () => {
33
+ let props
34
+
35
+ beforeEach(() => {
36
+ props = {
37
+ showStatus: true,
38
+ showPremiumLabel: true,
39
+ showScoopLabel: true,
40
+ status: 'inprogress',
41
+ indicators: { isScoop: true, accessLevel: 'premium' },
42
+ firstPublishedDate: '2025-10-01T00:00:00.000Z',
43
+ publishedDate: '2025-10-01T00:00:00.000Z',
44
+ useRelativeTimeIfToday: true,
45
+ useRelativeTime: true
46
+ }
47
+ })
48
+
49
+ afterEach(() => {
50
+ jest.clearAllMocks()
51
+ })
52
+
53
+ describe('LiveBlogStatus', () => {
54
+ it('renders only LiveBlogStatus when LiveBlogStatus props are present', () => {
55
+ mount(<Status {...props} />)
56
+
57
+ expect(LiveBlogStatusSpy).toHaveBeenCalledTimes(1)
58
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
59
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
60
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
61
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
62
+ expect(TimeStampSpy).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('should not render LiveBlogStatus when status is not present', () => {
66
+ delete props.status
67
+ mount(<Status {...props} />)
68
+
69
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
70
+ })
71
+
72
+ it('should not render LiveBlogStatus when showStatus is false', () => {
73
+ props.showStatus = false
74
+ mount(<Status {...props} />)
75
+
76
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
77
+ })
78
+ })
79
+
80
+ describe('ScoopLabel', () => {
81
+ beforeEach(() => {
82
+ delete props.status
83
+ })
84
+
85
+ it('renders only ScoopLabel when higher-level render props are absent and ScoopLabel props are present', () => {
86
+ mount(<Status {...props} />)
87
+
88
+ expect(ScoopLabelSpy).toHaveBeenCalledTimes(1)
89
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
90
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
91
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
92
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
93
+ expect(TimeStampSpy).not.toHaveBeenCalled()
94
+ })
95
+
96
+ it('should not render ScoopLabel when showScoopLabel = false', () => {
97
+ props.showScoopLabel = false
98
+ mount(<Status {...props} />)
99
+
100
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
101
+ })
102
+
103
+ it('should not render ScoopLabel when props.indicators.isScoop = false', () => {
104
+ props.indicators.isScoop = false
105
+ mount(<Status {...props} />)
106
+
107
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
108
+ })
109
+
110
+ it('should not render ScoopLabel when firstPublishedDate is older than the cutoff date', () => {
111
+ props.firstPublishedDate = '2025-09-01T00:00:00.000Z'
112
+ mount(<Status {...props} />)
113
+
114
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
115
+ })
116
+ })
117
+
118
+ describe('PremiumLabel', () => {
119
+ beforeEach(() => {
120
+ delete props.status
121
+ props.showScoopLabel = false
122
+ })
123
+
124
+ it('renders only PremiumLabel when higher-level render props are absent and PremiumLabel props are present', () => {
125
+ mount(<Status {...props} />)
126
+
127
+ expect(PremiumLabelSpy).toHaveBeenCalledTimes(1)
128
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
129
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
130
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
131
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
132
+ expect(TimeStampSpy).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('should not render PremiumLabel when showPremiumLabel = false', () => {
136
+ props.showPremiumLabel = false
137
+ mount(<Status {...props} />)
138
+
139
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('should not render PremiumLabel when accessLevel is not premium', () => {
143
+ props.indicators.accessLevel = 'subscribed'
144
+ mount(<Status {...props} />)
145
+
146
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
147
+ })
148
+ })
149
+
150
+ describe('AlwaysShowTimestamp', () => {
151
+ beforeEach(() => {
152
+ delete props.status
153
+ props.showScoopLabel = false
154
+ props.showPremiumLabel = false
155
+ })
156
+
157
+ it('renders only AlwaysShowTimestamp when higher-level render props are absent and AlwaysShowTimestamp props are present', () => {
158
+ mount(<Status {...props} />)
159
+
160
+ expect(AlwaysShowTimestampSpy).toHaveBeenCalledTimes(1)
161
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
162
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
163
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
164
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
165
+ expect(TimeStampSpy).not.toHaveBeenCalled()
166
+ })
167
+
168
+ it('should not render AlwaysShowTimestamp when showStatus = false', () => {
169
+ props.showStatus = false
170
+ mount(<Status {...props} />)
171
+
172
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
173
+ })
174
+
175
+ it('should not render AlwaysShowTimestamp when publishedDate is not present', () => {
176
+ delete props.publishedDate
177
+ mount(<Status {...props} />)
178
+
179
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
180
+ })
181
+
182
+ it('should not render AlwaysShowTimestamp when useRelativeTimeIfToday = false', () => {
183
+ props.useRelativeTimeIfToday = false
184
+ mount(<Status {...props} />)
185
+
186
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
187
+ })
188
+ })
189
+
190
+ describe('RelativeTime', () => {
191
+ beforeEach(() => {
192
+ delete props.status
193
+ props.showScoopLabel = false
194
+ props.showPremiumLabel = false
195
+ props.useRelativeTimeIfToday = false
196
+ })
197
+
198
+ it('renders only RelativeTime when higher-level render props are absent and RelativeTime props are present', () => {
199
+ mount(<Status {...props} />)
200
+
201
+ expect(RelativeTimeSpy).toHaveBeenCalledTimes(1)
202
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
203
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
204
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
205
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
206
+ expect(TimeStampSpy).not.toHaveBeenCalled()
207
+ })
208
+
209
+ it('should not render RelativeTime when showStatus = false', () => {
210
+ props.showStatus = false
211
+ mount(<Status {...props} />)
212
+
213
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it('should not render RelativeTime when publishedDate is not present', () => {
217
+ delete props.publishedDate
218
+ mount(<Status {...props} />)
219
+
220
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
221
+ })
222
+
223
+ it('should not render RelativeTime when useRelativeTime = false', () => {
224
+ props.useRelativeTime = false
225
+ mount(<Status {...props} />)
226
+
227
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
228
+ })
229
+ })
230
+
231
+ describe('TimeStamp', () => {
232
+ beforeEach(() => {
233
+ delete props.status
234
+ props.showScoopLabel = false
235
+ props.showPremiumLabel = false
236
+ props.useRelativeTimeIfToday = false
237
+ props.useRelativeTime = false
238
+ })
239
+
240
+ it('renders only TimeStamp when higher-level render props are absent and TimeStamp props are present', () => {
241
+ mount(<Status {...props} />)
242
+
243
+ expect(TimeStampSpy).toHaveBeenCalledTimes(1)
244
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
245
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
246
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
247
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
248
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
249
+ })
250
+
251
+ it('should not render TimeStamp when showStatus = false', () => {
252
+ props.showStatus = false
253
+ mount(<Status {...props} />)
254
+
255
+ expect(TimeStampSpy).not.toHaveBeenCalled()
256
+ })
257
+
258
+ it('should not render TimeStamp when publishedDate is not present', () => {
259
+ delete props.publishedDate
260
+ mount(<Status {...props} />)
261
+
262
+ expect(TimeStampSpy).not.toHaveBeenCalled()
263
+ })
264
+ })
265
+
266
+ describe('No Status rendered', () => {
267
+ it('should not render any Status - Case 1: all showXXX properties are set to false', () => {
268
+ props = {
269
+ showStatus: false,
270
+ showPremiumLabel: false,
271
+ showScoopLabel: false,
272
+ status: 'inprogress',
273
+ indicators: { isScoop: true, accessLevel: 'premium' },
274
+ firstPublishedDate: '2025-10-01T00:00:00.000Z',
275
+ publishedDate: '2025-10-01T00:00:00.000Z',
276
+ useRelativeTimeIfToday: true,
277
+ useRelativeTime: true
278
+ }
279
+ const subject = mount(<Status {...props} />)
280
+
281
+ expect(subject.isEmptyRender()).toBeTruthy()
282
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
283
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
284
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
285
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
286
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
287
+ expect(TimeStampSpy).not.toHaveBeenCalled()
288
+ })
289
+
290
+ it('should not render any Status - Case 2: showStatus = true but other properties are not present', () => {
291
+ // status & publishedDate are missing
292
+ props = {
293
+ showStatus: true,
294
+ showPremiumLabel: false,
295
+ showScoopLabel: false,
296
+ indicators: { isScoop: true, accessLevel: 'premium' },
297
+ firstPublishedDate: '2025-10-01T00:00:00.000Z',
298
+ useRelativeTimeIfToday: true,
299
+ useRelativeTime: true
300
+ }
301
+ const subject = mount(<Status {...props} />)
302
+
303
+ expect(subject.isEmptyRender()).toBeTruthy()
304
+ expect(LiveBlogStatusSpy).not.toHaveBeenCalled()
305
+ expect(ScoopLabelSpy).not.toHaveBeenCalled()
306
+ expect(PremiumLabelSpy).not.toHaveBeenCalled()
307
+ expect(AlwaysShowTimestampSpy).not.toHaveBeenCalled()
308
+ expect(RelativeTimeSpy).not.toHaveBeenCalled()
309
+ expect(TimeStampSpy).not.toHaveBeenCalled()
310
+ })
311
+ })
312
+ })
@@ -32,7 +32,7 @@ const rulesets = {
32
32
  if (props.theme) {
33
33
  return props.theme;
34
34
  }
35
- if (props.status === 'inprogress') {
35
+ if (props.status === 'inprogress' && props.allowLiveTeaserStyling) {
36
36
  return 'live';
37
37
  }
38
38
  if (props.indicators && props.indicators.isOpinion) {
@@ -437,23 +437,23 @@ var RelativeTime = ({
437
437
  }, displayTime(relativeDate))) : null;
438
438
  };
439
439
 
440
- const LiveBlogLabels = {
441
- inprogress: 'Live',
442
- comingsoon: 'Coming Soon',
443
- closed: ''
444
- };
445
440
  const LiveBlogModifiers = {
446
441
  inprogress: 'live',
447
442
  comingsoon: 'pending',
448
443
  closed: 'closed'
449
444
  };
450
445
  var LiveBlogStatus = ({
451
- status
446
+ status,
447
+ allowLiveTeaserStyling = false
452
448
  }) => status && status !== 'closed' ? xEngine.h("div", {
453
449
  className: `o-teaser__timestamp o-teaser__timestamp--${LiveBlogModifiers[status]}`
454
- }, xEngine.h("span", {
450
+ }, status === 'comingsoon' && xEngine.h("span", {
455
451
  className: "o-teaser__timestamp-prefix"
456
- }, ` ${LiveBlogLabels[status]} `)) : null;
452
+ }, ` Coming Soon `), status === 'inprogress' && xEngine.h("span", {
453
+ className: `o-labels-indicator o-labels-indicator--live ${allowLiveTeaserStyling ? null : 'o-labels-indicator--badge'}`
454
+ }, xEngine.h("span", {
455
+ className: "o-labels-indicator__status"
456
+ }, ` Live `))) : null;
457
457
 
458
458
  /**
459
459
  * Timestamp shown always, the default 4h limit does not apply here
@@ -473,32 +473,52 @@ var AlwaysShowTimestamp = props => {
473
473
  };
474
474
 
475
475
  function PremiumLabel() {
476
+ return (
477
+ // WARNING: Do not use the x-teaser__premium-label class to override styling.
478
+ // The styling should be in o-teaser, not x-teaser.
479
+ // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label.
480
+ xEngine.h("div", {
481
+ className: "x-teaser__premium-label o-teaser__labels o-teaser__labels--premium"
482
+ }, xEngine.h("span", {
483
+ className: "o-labels o-labels--premium o-labels--content-premium"
484
+ }, "Premium"), xEngine.h("span", {
485
+ className: "o3-visually-hidden"
486
+ }, "\xA0content"))
487
+ );
488
+ }
489
+
490
+ function ScoopLabel() {
476
491
  return xEngine.h("div", {
477
- className: "x-teaser__premium-label"
492
+ className: "o-teaser__labels o-teaser__labels--scoop"
478
493
  }, xEngine.h("span", {
479
- className: "o-labels o-labels--premium o-labels--content-premium"
480
- }, "Premium"), xEngine.h("span", {
494
+ className: "o-labels o-labels--content-scoop"
495
+ }, "Exclusive"), xEngine.h("span", {
481
496
  className: "o3-visually-hidden"
482
497
  }, "\xA0content"));
483
498
  }
484
499
 
485
500
  var Status = props => {
486
- var _props$indicators;
487
- if (props.showPremiumLabel && (props === null || props === void 0 || (_props$indicators = props.indicators) === null || _props$indicators === void 0 ? void 0 : _props$indicators.accessLevel) === 'premium') {
501
+ var _props$indicators, _props$indicators2;
502
+ if (props.showStatus && props.status) {
503
+ return xEngine.h(LiveBlogStatus, props);
504
+ }
505
+ if (props.showScoopLabel && props !== null && props !== void 0 && (_props$indicators = props.indicators) !== null && _props$indicators !== void 0 && _props$indicators.isScoop &&
506
+ // We plan to show the Scoop label only on homepages.
507
+ // If we later show it on other pages, this cutoff date will need review.
508
+ // The `isScoop` property already exists, but Editorial will use it differently after 2025-10-01.
509
+ new Date(props.firstPublishedDate) >= new Date('2025-10-01T00:00:00.000Z')) {
510
+ return xEngine.h(ScoopLabel, props);
511
+ }
512
+ if (props.showPremiumLabel && (props === null || props === void 0 || (_props$indicators2 = props.indicators) === null || _props$indicators2 === void 0 ? void 0 : _props$indicators2.accessLevel) === 'premium') {
488
513
  return xEngine.h(PremiumLabel, props);
489
514
  }
490
- if (props.showStatus) {
491
- if (props.status) {
492
- return xEngine.h(LiveBlogStatus, props);
493
- }
494
- if (props.publishedDate) {
495
- if (props.useRelativeTimeIfToday) {
496
- return xEngine.h(AlwaysShowTimestamp, props);
497
- } else if (props.useRelativeTime) {
498
- return xEngine.h(RelativeTime, props);
499
- } else {
500
- return xEngine.h(TimeStamp, props);
501
- }
515
+ if (props.showStatus && props.publishedDate) {
516
+ if (props.useRelativeTimeIfToday) {
517
+ return xEngine.h(AlwaysShowTimestamp, props);
518
+ } else if (props.useRelativeTime) {
519
+ return xEngine.h(RelativeTime, props);
520
+ } else {
521
+ return xEngine.h(TimeStamp, props);
502
522
  }
503
523
  }
504
524
  return null;
@@ -604,6 +624,7 @@ const Small = {
604
624
  showMeta: true,
605
625
  showTitle: true,
606
626
  showPremiumLabel: true,
627
+ showScoopLabel: false,
607
628
  showStatus: true
608
629
  };
609
630
  const SmallHeavy = {
@@ -613,6 +634,7 @@ const SmallHeavy = {
613
634
  showTitle: true,
614
635
  showStandfirst: true,
615
636
  showPremiumLabel: true,
637
+ showScoopLabel: false,
616
638
  showStatus: true,
617
639
  showImage: true,
618
640
  imageSize: 'Small'
@@ -624,6 +646,7 @@ const Large = {
624
646
  showTitle: true,
625
647
  showStandfirst: true,
626
648
  showPremiumLabel: true,
649
+ showScoopLabel: false,
627
650
  showStatus: true,
628
651
  showImage: true,
629
652
  imageSize: 'Medium'
@@ -634,6 +657,7 @@ const Hero = {
634
657
  showMeta: true,
635
658
  showTitle: true,
636
659
  showPremiumLabel: true,
660
+ showScoopLabel: false,
637
661
  showStatus: true,
638
662
  showImage: true,
639
663
  imageSize: 'Medium'
@@ -645,6 +669,7 @@ const HeroNarrow = {
645
669
  showTitle: true,
646
670
  showStandfirst: true,
647
671
  showPremiumLabel: true,
672
+ showScoopLabel: false,
648
673
  showStatus: true
649
674
  };
650
675
  const HeroVideo = {
@@ -661,6 +686,7 @@ const HeroOverlay = {
661
686
  showMeta: true,
662
687
  showTitle: true,
663
688
  showPremiumLabel: true,
689
+ showScoopLabel: false,
664
690
  showStatus: true,
665
691
  showImage: true,
666
692
  imageSize: 'XL',
@@ -673,6 +699,7 @@ const TopStory = {
673
699
  showTitle: true,
674
700
  showStandfirst: true,
675
701
  showPremiumLabel: true,
702
+ showScoopLabel: false,
676
703
  showStatus: true,
677
704
  showRelatedLinks: true
678
705
  };
@@ -683,6 +710,7 @@ const TopStoryLandscape = {
683
710
  showTitle: true,
684
711
  showStandfirst: true,
685
712
  showPremiumLabel: true,
713
+ showScoopLabel: false,
686
714
  showStatus: true,
687
715
  showImage: true,
688
716
  imageSize: 'XL',
@@ -32,7 +32,7 @@ var rulesets = {
32
32
  if (props.theme) {
33
33
  return props.theme;
34
34
  }
35
- if (props.status === 'inprogress') {
35
+ if (props.status === 'inprogress' && props.allowLiveTeaserStyling) {
36
36
  return 'live';
37
37
  }
38
38
  if (props.indicators && props.indicators.isOpinion) {
@@ -524,23 +524,24 @@ var RelativeTime = (function (_ref) {
524
524
  }, displayTime(relativeDate))) : null;
525
525
  });
526
526
 
527
- var LiveBlogLabels = {
528
- inprogress: 'Live',
529
- comingsoon: 'Coming Soon',
530
- closed: ''
531
- };
532
527
  var LiveBlogModifiers = {
533
528
  inprogress: 'live',
534
529
  comingsoon: 'pending',
535
530
  closed: 'closed'
536
531
  };
537
532
  var LiveBlogStatus = (function (_ref) {
538
- var status = _ref.status;
533
+ var status = _ref.status,
534
+ _ref$allowLiveTeaserS = _ref.allowLiveTeaserStyling,
535
+ allowLiveTeaserStyling = _ref$allowLiveTeaserS === void 0 ? false : _ref$allowLiveTeaserS;
539
536
  return status && status !== 'closed' ? xEngine.h("div", {
540
537
  className: "o-teaser__timestamp o-teaser__timestamp--".concat(LiveBlogModifiers[status])
541
- }, xEngine.h("span", {
538
+ }, status === 'comingsoon' && xEngine.h("span", {
542
539
  className: "o-teaser__timestamp-prefix"
543
- }, " ".concat(LiveBlogLabels[status], " "))) : null;
540
+ }, " Coming Soon "), status === 'inprogress' && xEngine.h("span", {
541
+ className: "o-labels-indicator o-labels-indicator--live ".concat(allowLiveTeaserStyling ? null : 'o-labels-indicator--badge')
542
+ }, xEngine.h("span", {
543
+ className: "o-labels-indicator__status"
544
+ }, " Live "))) : null;
544
545
  });
545
546
 
546
547
  /**
@@ -561,32 +562,52 @@ var AlwaysShowTimestamp = (function (props) {
561
562
  });
562
563
 
563
564
  function PremiumLabel() {
565
+ return (
566
+ // WARNING: Do not use the x-teaser__premium-label class to override styling.
567
+ // The styling should be in o-teaser, not x-teaser.
568
+ // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label.
569
+ xEngine.h("div", {
570
+ className: "x-teaser__premium-label o-teaser__labels o-teaser__labels--premium"
571
+ }, xEngine.h("span", {
572
+ className: "o-labels o-labels--premium o-labels--content-premium"
573
+ }, "Premium"), xEngine.h("span", {
574
+ className: "o3-visually-hidden"
575
+ }, "\xA0content"))
576
+ );
577
+ }
578
+
579
+ function ScoopLabel() {
564
580
  return xEngine.h("div", {
565
- className: "x-teaser__premium-label"
581
+ className: "o-teaser__labels o-teaser__labels--scoop"
566
582
  }, xEngine.h("span", {
567
- className: "o-labels o-labels--premium o-labels--content-premium"
568
- }, "Premium"), xEngine.h("span", {
583
+ className: "o-labels o-labels--content-scoop"
584
+ }, "Exclusive"), xEngine.h("span", {
569
585
  className: "o3-visually-hidden"
570
586
  }, "\xA0content"));
571
587
  }
572
588
 
573
589
  var Status = (function (props) {
574
- var _props$indicators;
575
- if (props.showPremiumLabel && (props === null || props === void 0 || (_props$indicators = props.indicators) === null || _props$indicators === void 0 ? void 0 : _props$indicators.accessLevel) === 'premium') {
590
+ var _props$indicators, _props$indicators2;
591
+ if (props.showStatus && props.status) {
592
+ return xEngine.h(LiveBlogStatus, props);
593
+ }
594
+ if (props.showScoopLabel && props !== null && props !== void 0 && (_props$indicators = props.indicators) !== null && _props$indicators !== void 0 && _props$indicators.isScoop &&
595
+ // We plan to show the Scoop label only on homepages.
596
+ // If we later show it on other pages, this cutoff date will need review.
597
+ // The `isScoop` property already exists, but Editorial will use it differently after 2025-10-01.
598
+ new Date(props.firstPublishedDate) >= new Date('2025-10-01T00:00:00.000Z')) {
599
+ return xEngine.h(ScoopLabel, props);
600
+ }
601
+ if (props.showPremiumLabel && (props === null || props === void 0 || (_props$indicators2 = props.indicators) === null || _props$indicators2 === void 0 ? void 0 : _props$indicators2.accessLevel) === 'premium') {
576
602
  return xEngine.h(PremiumLabel, props);
577
603
  }
578
- if (props.showStatus) {
579
- if (props.status) {
580
- return xEngine.h(LiveBlogStatus, props);
581
- }
582
- if (props.publishedDate) {
583
- if (props.useRelativeTimeIfToday) {
584
- return xEngine.h(AlwaysShowTimestamp, props);
585
- } else if (props.useRelativeTime) {
586
- return xEngine.h(RelativeTime, props);
587
- } else {
588
- return xEngine.h(TimeStamp, props);
589
- }
604
+ if (props.showStatus && props.publishedDate) {
605
+ if (props.useRelativeTimeIfToday) {
606
+ return xEngine.h(AlwaysShowTimestamp, props);
607
+ } else if (props.useRelativeTime) {
608
+ return xEngine.h(RelativeTime, props);
609
+ } else {
610
+ return xEngine.h(TimeStamp, props);
590
611
  }
591
612
  }
592
613
  return null;
@@ -697,6 +718,7 @@ var Small = {
697
718
  showMeta: true,
698
719
  showTitle: true,
699
720
  showPremiumLabel: true,
721
+ showScoopLabel: false,
700
722
  showStatus: true
701
723
  };
702
724
  var SmallHeavy = {
@@ -706,6 +728,7 @@ var SmallHeavy = {
706
728
  showTitle: true,
707
729
  showStandfirst: true,
708
730
  showPremiumLabel: true,
731
+ showScoopLabel: false,
709
732
  showStatus: true,
710
733
  showImage: true,
711
734
  imageSize: 'Small'
@@ -717,6 +740,7 @@ var Large = {
717
740
  showTitle: true,
718
741
  showStandfirst: true,
719
742
  showPremiumLabel: true,
743
+ showScoopLabel: false,
720
744
  showStatus: true,
721
745
  showImage: true,
722
746
  imageSize: 'Medium'
@@ -727,6 +751,7 @@ var Hero = {
727
751
  showMeta: true,
728
752
  showTitle: true,
729
753
  showPremiumLabel: true,
754
+ showScoopLabel: false,
730
755
  showStatus: true,
731
756
  showImage: true,
732
757
  imageSize: 'Medium'
@@ -738,6 +763,7 @@ var HeroNarrow = {
738
763
  showTitle: true,
739
764
  showStandfirst: true,
740
765
  showPremiumLabel: true,
766
+ showScoopLabel: false,
741
767
  showStatus: true
742
768
  };
743
769
  var HeroVideo = {
@@ -754,6 +780,7 @@ var HeroOverlay = {
754
780
  showMeta: true,
755
781
  showTitle: true,
756
782
  showPremiumLabel: true,
783
+ showScoopLabel: false,
757
784
  showStatus: true,
758
785
  showImage: true,
759
786
  imageSize: 'XL',
@@ -766,6 +793,7 @@ var TopStory = {
766
793
  showTitle: true,
767
794
  showStandfirst: true,
768
795
  showPremiumLabel: true,
796
+ showScoopLabel: false,
769
797
  showStatus: true,
770
798
  showRelatedLinks: true
771
799
  };
@@ -776,6 +804,7 @@ var TopStoryLandscape = {
776
804
  showTitle: true,
777
805
  showStandfirst: true,
778
806
  showPremiumLabel: true,
807
+ showScoopLabel: false,
779
808
  showStatus: true,
780
809
  showImage: true,
781
810
  imageSize: 'XL',
@@ -26,7 +26,7 @@ const rulesets = {
26
26
  if (props.theme) {
27
27
  return props.theme;
28
28
  }
29
- if (props.status === 'inprogress') {
29
+ if (props.status === 'inprogress' && props.allowLiveTeaserStyling) {
30
30
  return 'live';
31
31
  }
32
32
  if (props.indicators && props.indicators.isOpinion) {
@@ -431,23 +431,23 @@ var RelativeTime = ({
431
431
  }, displayTime(relativeDate))) : null;
432
432
  };
433
433
 
434
- const LiveBlogLabels = {
435
- inprogress: 'Live',
436
- comingsoon: 'Coming Soon',
437
- closed: ''
438
- };
439
434
  const LiveBlogModifiers = {
440
435
  inprogress: 'live',
441
436
  comingsoon: 'pending',
442
437
  closed: 'closed'
443
438
  };
444
439
  var LiveBlogStatus = ({
445
- status
440
+ status,
441
+ allowLiveTeaserStyling = false
446
442
  }) => status && status !== 'closed' ? h("div", {
447
443
  className: `o-teaser__timestamp o-teaser__timestamp--${LiveBlogModifiers[status]}`
448
- }, h("span", {
444
+ }, status === 'comingsoon' && h("span", {
449
445
  className: "o-teaser__timestamp-prefix"
450
- }, ` ${LiveBlogLabels[status]} `)) : null;
446
+ }, ` Coming Soon `), status === 'inprogress' && h("span", {
447
+ className: `o-labels-indicator o-labels-indicator--live ${allowLiveTeaserStyling ? null : 'o-labels-indicator--badge'}`
448
+ }, h("span", {
449
+ className: "o-labels-indicator__status"
450
+ }, ` Live `))) : null;
451
451
 
452
452
  /**
453
453
  * Timestamp shown always, the default 4h limit does not apply here
@@ -467,32 +467,52 @@ var AlwaysShowTimestamp = props => {
467
467
  };
468
468
 
469
469
  function PremiumLabel() {
470
+ return (
471
+ // WARNING: Do not use the x-teaser__premium-label class to override styling.
472
+ // The styling should be in o-teaser, not x-teaser.
473
+ // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label.
474
+ h("div", {
475
+ className: "x-teaser__premium-label o-teaser__labels o-teaser__labels--premium"
476
+ }, h("span", {
477
+ className: "o-labels o-labels--premium o-labels--content-premium"
478
+ }, "Premium"), h("span", {
479
+ className: "o3-visually-hidden"
480
+ }, "\xA0content"))
481
+ );
482
+ }
483
+
484
+ function ScoopLabel() {
470
485
  return h("div", {
471
- className: "x-teaser__premium-label"
486
+ className: "o-teaser__labels o-teaser__labels--scoop"
472
487
  }, h("span", {
473
- className: "o-labels o-labels--premium o-labels--content-premium"
474
- }, "Premium"), h("span", {
488
+ className: "o-labels o-labels--content-scoop"
489
+ }, "Exclusive"), h("span", {
475
490
  className: "o3-visually-hidden"
476
491
  }, "\xA0content"));
477
492
  }
478
493
 
479
494
  var Status = props => {
480
- var _props$indicators;
481
- if (props.showPremiumLabel && (props === null || props === void 0 || (_props$indicators = props.indicators) === null || _props$indicators === void 0 ? void 0 : _props$indicators.accessLevel) === 'premium') {
495
+ var _props$indicators, _props$indicators2;
496
+ if (props.showStatus && props.status) {
497
+ return h(LiveBlogStatus, props);
498
+ }
499
+ if (props.showScoopLabel && props !== null && props !== void 0 && (_props$indicators = props.indicators) !== null && _props$indicators !== void 0 && _props$indicators.isScoop &&
500
+ // We plan to show the Scoop label only on homepages.
501
+ // If we later show it on other pages, this cutoff date will need review.
502
+ // The `isScoop` property already exists, but Editorial will use it differently after 2025-10-01.
503
+ new Date(props.firstPublishedDate) >= new Date('2025-10-01T00:00:00.000Z')) {
504
+ return h(ScoopLabel, props);
505
+ }
506
+ if (props.showPremiumLabel && (props === null || props === void 0 || (_props$indicators2 = props.indicators) === null || _props$indicators2 === void 0 ? void 0 : _props$indicators2.accessLevel) === 'premium') {
482
507
  return h(PremiumLabel, props);
483
508
  }
484
- if (props.showStatus) {
485
- if (props.status) {
486
- return h(LiveBlogStatus, props);
487
- }
488
- if (props.publishedDate) {
489
- if (props.useRelativeTimeIfToday) {
490
- return h(AlwaysShowTimestamp, props);
491
- } else if (props.useRelativeTime) {
492
- return h(RelativeTime, props);
493
- } else {
494
- return h(TimeStamp, props);
495
- }
509
+ if (props.showStatus && props.publishedDate) {
510
+ if (props.useRelativeTimeIfToday) {
511
+ return h(AlwaysShowTimestamp, props);
512
+ } else if (props.useRelativeTime) {
513
+ return h(RelativeTime, props);
514
+ } else {
515
+ return h(TimeStamp, props);
496
516
  }
497
517
  }
498
518
  return null;
@@ -598,6 +618,7 @@ const Small = {
598
618
  showMeta: true,
599
619
  showTitle: true,
600
620
  showPremiumLabel: true,
621
+ showScoopLabel: false,
601
622
  showStatus: true
602
623
  };
603
624
  const SmallHeavy = {
@@ -607,6 +628,7 @@ const SmallHeavy = {
607
628
  showTitle: true,
608
629
  showStandfirst: true,
609
630
  showPremiumLabel: true,
631
+ showScoopLabel: false,
610
632
  showStatus: true,
611
633
  showImage: true,
612
634
  imageSize: 'Small'
@@ -618,6 +640,7 @@ const Large = {
618
640
  showTitle: true,
619
641
  showStandfirst: true,
620
642
  showPremiumLabel: true,
643
+ showScoopLabel: false,
621
644
  showStatus: true,
622
645
  showImage: true,
623
646
  imageSize: 'Medium'
@@ -628,6 +651,7 @@ const Hero = {
628
651
  showMeta: true,
629
652
  showTitle: true,
630
653
  showPremiumLabel: true,
654
+ showScoopLabel: false,
631
655
  showStatus: true,
632
656
  showImage: true,
633
657
  imageSize: 'Medium'
@@ -639,6 +663,7 @@ const HeroNarrow = {
639
663
  showTitle: true,
640
664
  showStandfirst: true,
641
665
  showPremiumLabel: true,
666
+ showScoopLabel: false,
642
667
  showStatus: true
643
668
  };
644
669
  const HeroVideo = {
@@ -655,6 +680,7 @@ const HeroOverlay = {
655
680
  showMeta: true,
656
681
  showTitle: true,
657
682
  showPremiumLabel: true,
683
+ showScoopLabel: false,
658
684
  showStatus: true,
659
685
  showImage: true,
660
686
  imageSize: 'XL',
@@ -667,6 +693,7 @@ const TopStory = {
667
693
  showTitle: true,
668
694
  showStandfirst: true,
669
695
  showPremiumLabel: true,
696
+ showScoopLabel: false,
670
697
  showStatus: true,
671
698
  showRelatedLinks: true
672
699
  };
@@ -677,6 +704,7 @@ const TopStoryLandscape = {
677
704
  showTitle: true,
678
705
  showStandfirst: true,
679
706
  showPremiumLabel: true,
707
+ showScoopLabel: false,
680
708
  showStatus: true,
681
709
  showImage: true,
682
710
  imageSize: 'XL',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/x-teaser",
3
- "version": "17.0.5",
3
+ "version": "18.1.0",
4
4
  "description": "This module provides templates for use with o-teaser. Teasers are used to present content.",
5
5
  "source": "src/Teaser.jsx",
6
6
  "main": "dist/Teaser.cjs.js",
@@ -18,7 +18,7 @@
18
18
  "author": "",
19
19
  "license": "ISC",
20
20
  "dependencies": {
21
- "@financial-times/x-engine": "^17.0.5",
21
+ "@financial-times/x-engine": "^18.1.0",
22
22
  "date-fns": "^2.30.0",
23
23
  "dateformat": "^3.0.3"
24
24
  },
package/readme.md CHANGED
@@ -114,6 +114,7 @@ As covered in the [features](#features) documentation the teaser properties, or
114
114
  | `showTitle` | Boolean |
115
115
  | `showStandfirst` | Boolean |
116
116
  | `showPremiumLabel` | Boolean |
117
+ | `showScoopLabel` | Boolean |
117
118
  | `showStatus` | Boolean |
118
119
  | `showImage` | Boolean |
119
120
  | `showHeadshot` | Boolean | Takes precedence over image |
@@ -257,6 +258,12 @@ As covered in the [features](#features) documentation the teaser properties, or
257
258
  | `isExclusive` | Boolean |
258
259
  | `isScoop` | Boolean |
259
260
 
261
+ #### Special Styling Props
262
+
263
+ | Property | Type | Notes |
264
+ | ------------------------ | ------- | ------------------------------------------ |
265
+ | `allowLiveTeaserStyling` | Boolean | Apply `o-teaser--live` class to Container. :memo: *Consumers need to include the o-teaser styling in their applications too.* |
266
+
260
267
  ### Presets
261
268
 
262
269
  Because there are so many options presets are available for the most commonly used configurations, these are:-
@@ -1,20 +1,23 @@
1
1
  import { h } from '@financial-times/x-engine'
2
2
 
3
- const LiveBlogLabels = {
4
- inprogress: 'Live',
5
- comingsoon: 'Coming Soon',
6
- closed: ''
7
- }
8
-
9
3
  const LiveBlogModifiers = {
10
4
  inprogress: 'live',
11
5
  comingsoon: 'pending',
12
6
  closed: 'closed'
13
7
  }
14
8
 
15
- export default ({ status }) =>
9
+ export default ({ status, allowLiveTeaserStyling = false }) =>
16
10
  status && status !== 'closed' ? (
17
11
  <div className={`o-teaser__timestamp o-teaser__timestamp--${LiveBlogModifiers[status]}`}>
18
- <span className="o-teaser__timestamp-prefix">{` ${LiveBlogLabels[status]} `}</span>
12
+ {status === 'comingsoon' && <span className="o-teaser__timestamp-prefix">{` Coming Soon `}</span>}
13
+ {status === 'inprogress' && (
14
+ <span
15
+ className={`o-labels-indicator o-labels-indicator--live ${
16
+ allowLiveTeaserStyling ? null : 'o-labels-indicator--badge'
17
+ }`}
18
+ >
19
+ <span className="o-labels-indicator__status">{` Live `}</span>
20
+ </span>
21
+ )}
19
22
  </div>
20
23
  ) : null
@@ -2,7 +2,10 @@ import { h } from '@financial-times/x-engine'
2
2
 
3
3
  export default function PremiumLabel() {
4
4
  return (
5
- <div className="x-teaser__premium-label">
5
+ // WARNING: Do not use the x-teaser__premium-label class to override styling.
6
+ // The styling should be in o-teaser, not x-teaser.
7
+ // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label.
8
+ <div className="x-teaser__premium-label o-teaser__labels o-teaser__labels--premium">
6
9
  <span className="o-labels o-labels--premium o-labels--content-premium">Premium</span>
7
10
  <span className="o3-visually-hidden">&nbsp;content</span>
8
11
  </div>
@@ -0,0 +1,10 @@
1
+ import { h } from '@financial-times/x-engine'
2
+
3
+ export default function ScoopLabel() {
4
+ return (
5
+ <div className="o-teaser__labels o-teaser__labels--scoop">
6
+ <span className="o-labels o-labels--content-scoop">Exclusive</span>
7
+ <span className="o3-visually-hidden">&nbsp;content</span>
8
+ </div>
9
+ )
10
+ }
package/src/Status.jsx CHANGED
@@ -4,25 +4,35 @@ import RelativeTime from './RelativeTime'
4
4
  import LiveBlogStatus from './LiveBlogStatus'
5
5
  import AlwaysShowTimestamp from './AlwaysShowTimestamp'
6
6
  import PremiumLabel from './PremiumLabel'
7
+ import ScoopLabel from './ScoopLabel'
7
8
 
8
9
  export default (props) => {
10
+ if (props.showStatus && props.status) {
11
+ return <LiveBlogStatus {...props} />
12
+ }
13
+
14
+ if (
15
+ props.showScoopLabel &&
16
+ props?.indicators?.isScoop &&
17
+ // We plan to show the Scoop label only on homepages.
18
+ // If we later show it on other pages, this cutoff date will need review.
19
+ // The `isScoop` property already exists, but Editorial will use it differently after 2025-10-01.
20
+ new Date(props.firstPublishedDate) >= new Date('2025-10-01T00:00:00.000Z')
21
+ ) {
22
+ return <ScoopLabel {...props} />
23
+ }
24
+
9
25
  if (props.showPremiumLabel && props?.indicators?.accessLevel === 'premium') {
10
26
  return <PremiumLabel {...props} />
11
27
  }
12
28
 
13
- if (props.showStatus) {
14
- if (props.status) {
15
- return <LiveBlogStatus {...props} />
16
- }
17
-
18
- if (props.publishedDate) {
19
- if (props.useRelativeTimeIfToday) {
20
- return <AlwaysShowTimestamp {...props} />
21
- } else if (props.useRelativeTime) {
22
- return <RelativeTime {...props} />
23
- } else {
24
- return <TimeStamp {...props} />
25
- }
29
+ if (props.showStatus && props.publishedDate) {
30
+ if (props.useRelativeTimeIfToday) {
31
+ return <AlwaysShowTimestamp {...props} />
32
+ } else if (props.useRelativeTime) {
33
+ return <RelativeTime {...props} />
34
+ } else {
35
+ return <TimeStamp {...props} />
26
36
  }
27
37
  }
28
38
 
package/src/Teaser.scss CHANGED
@@ -1,5 +1,9 @@
1
1
  @import '@financial-times/o3-foundation/css/core.css';
2
2
 
3
+ // WARNING: Do not use the x-teaser__premium-label class to override styling.
4
+ // The styling should be in o-teaser, not x-teaser.
5
+ // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label.
6
+
3
7
  // Theses styles copy the spacing from o-teaser__timestamp
4
8
  // as the premium label is replacing it
5
9
  .x-teaser__premium-label {
@@ -6,6 +6,7 @@ const Small = {
6
6
  showMeta: true,
7
7
  showTitle: true,
8
8
  showPremiumLabel: true,
9
+ showScoopLabel: false,
9
10
  showStatus: true
10
11
  }
11
12
 
@@ -16,6 +17,7 @@ const SmallHeavy = {
16
17
  showTitle: true,
17
18
  showStandfirst: true,
18
19
  showPremiumLabel: true,
20
+ showScoopLabel: false,
19
21
  showStatus: true,
20
22
  showImage: true,
21
23
  imageSize: 'Small'
@@ -28,6 +30,7 @@ const Large = {
28
30
  showTitle: true,
29
31
  showStandfirst: true,
30
32
  showPremiumLabel: true,
33
+ showScoopLabel: false,
31
34
  showStatus: true,
32
35
  showImage: true,
33
36
  imageSize: 'Medium'
@@ -39,6 +42,7 @@ const Hero = {
39
42
  showMeta: true,
40
43
  showTitle: true,
41
44
  showPremiumLabel: true,
45
+ showScoopLabel: false,
42
46
  showStatus: true,
43
47
  showImage: true,
44
48
  imageSize: 'Medium'
@@ -51,6 +55,7 @@ const HeroNarrow = {
51
55
  showTitle: true,
52
56
  showStandfirst: true,
53
57
  showPremiumLabel: true,
58
+ showScoopLabel: false,
54
59
  showStatus: true
55
60
  }
56
61
 
@@ -69,6 +74,7 @@ const HeroOverlay = {
69
74
  showMeta: true,
70
75
  showTitle: true,
71
76
  showPremiumLabel: true,
77
+ showScoopLabel: false,
72
78
  showStatus: true,
73
79
  showImage: true,
74
80
  imageSize: 'XL',
@@ -82,6 +88,7 @@ const TopStory = {
82
88
  showTitle: true,
83
89
  showStandfirst: true,
84
90
  showPremiumLabel: true,
91
+ showScoopLabel: false,
85
92
  showStatus: true,
86
93
  showRelatedLinks: true
87
94
  }
@@ -93,6 +100,7 @@ const TopStoryLandscape = {
93
100
  showTitle: true,
94
101
  showStandfirst: true,
95
102
  showPremiumLabel: true,
103
+ showScoopLabel: false,
96
104
  showStatus: true,
97
105
  showImage: true,
98
106
  imageSize: 'XL',
@@ -26,7 +26,7 @@ const rulesets = {
26
26
  return props.theme
27
27
  }
28
28
 
29
- if (props.status === 'inprogress') {
29
+ if (props.status === 'inprogress' && props.allowLiveTeaserStyling) {
30
30
  return 'live'
31
31
  }
32
32
 
@@ -47,5 +47,9 @@ exports.argTypes = {
47
47
  }
48
48
  },
49
49
  publishedDate: { name: 'Published Date', control: { type: 'date' } },
50
- firstPublishedDate: { name: 'First Published Date', control: { type: 'date' } }
50
+ firstPublishedDate: { name: 'First Published Date', control: { type: 'date' } },
51
+ allowLiveTeaserStyling: {
52
+ name: 'allowLiveTeaserStyling',
53
+ control: 'boolean'
54
+ }
51
55
  }
@@ -1,6 +1,8 @@
1
1
  const { presets } = require('../')
2
2
 
3
- exports.args = Object.assign(require('../__fixtures__/article.json'), presets.SmallHeavy)
3
+ exports.args = Object.assign(require('../__fixtures__/article.json'), presets.SmallHeavy, {
4
+ allowLiveTeaserStyling: false
5
+ })
4
6
 
5
7
  // This reference is only required for hot module loading in development
6
8
  // <https://webpack.js.org/concepts/hot-module-replacement/>
@@ -5,9 +5,9 @@ import BuildService from '../../../.storybook/build-service'
5
5
  import '../src/Teaser.scss'
6
6
 
7
7
  const dependencies = {
8
- 'o-date': '^5.3.0',
9
- 'o-labels': '^7.0.1',
10
- 'o-teaser': '^7.1.3',
8
+ 'o-date': '^7.0.1',
9
+ 'o-labels': '^7.1.0',
10
+ 'o-teaser': '^9.1.0',
11
11
  'o-video': '^8.0.0'
12
12
  }
13
13