@learncard/react 2.11.0 → 2.12.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.
Files changed (127) hide show
  1. package/dist/base.css +1 -1
  2. package/dist/base.js +1 -1
  3. package/dist/cjs/{BoostGenericCard-feaf99bd.js → BoostGenericCard-71899305.js} +22 -3
  4. package/dist/cjs/BoostGenericCard-71899305.js.map +1 -0
  5. package/dist/cjs/{CertificateDisplayCard-de7d7318.js → CertificateDisplayCard-f10b46b1.js} +5 -5
  6. package/dist/cjs/{CertificateDisplayCard-de7d7318.js.map → CertificateDisplayCard-f10b46b1.js.map} +1 -1
  7. package/dist/cjs/{Lightbox-d72e19ba.js → Lightbox-e2996736.js} +100 -2
  8. package/dist/cjs/Lightbox-e2996736.js.map +1 -0
  9. package/dist/cjs/{MeritBadgeDisplayCard-6edd0a69.js → MeritBadgeDisplayCard-d50cfb5e.js} +5 -5
  10. package/dist/cjs/{MeritBadgeDisplayCard-6edd0a69.js.map → MeritBadgeDisplayCard-d50cfb5e.js.map} +1 -1
  11. package/dist/cjs/{MeritBadgeSkillsCount-7ac48ce3.js → MeritBadgeSkillsCount-e46503f5.js} +2 -2
  12. package/dist/cjs/{MeritBadgeSkillsCount-7ac48ce3.js.map → MeritBadgeSkillsCount-e46503f5.js.map} +1 -1
  13. package/dist/cjs/{SchoolIDCard-10796199.js → SchoolIDCard-2d3cbb2c.js} +3 -1184
  14. package/dist/cjs/SchoolIDCard-2d3cbb2c.js.map +1 -0
  15. package/dist/cjs/{VCCard-0b85bc7d.js → VCCard-42479c97.js} +3 -3
  16. package/dist/cjs/{VCCard-0b85bc7d.js.map → VCCard-42479c97.js.map} +1 -1
  17. package/dist/cjs/{VCDisplayBackFace-45a8d94f.js → VCDisplayBackFace-d1cf9f73.js} +3 -3
  18. package/dist/cjs/{VCDisplayBackFace-45a8d94f.js.map → VCDisplayBackFace-d1cf9f73.js.map} +1 -1
  19. package/dist/cjs/{VCDisplayCard-9e43c7db.js → VCDisplayCard-cf46c224.js} +3 -3
  20. package/dist/cjs/{VCDisplayCard-9e43c7db.js.map → VCDisplayCard-cf46c224.js.map} +1 -1
  21. package/dist/cjs/{VCDisplayCard2-81d04ca4.js → VCDisplayCard2-4a201a8a.js} +93 -68
  22. package/dist/cjs/VCDisplayCard2-4a201a8a.js.map +1 -0
  23. package/dist/cjs/{VCVerificationPill-9416f420.js → VCVerificationPill-6c9dcf80.js} +2 -2
  24. package/dist/cjs/{VCVerificationPill-9416f420.js.map → VCVerificationPill-6c9dcf80.js.map} +1 -1
  25. package/dist/cjs/{VerifierStateBadgeAndText-54065ad8.js → VerifierStateBadgeAndText-934239ab.js} +13 -4
  26. package/dist/{esm/VerifierStateBadgeAndText-2859597e.js.map → cjs/VerifierStateBadgeAndText-934239ab.js.map} +1 -1
  27. package/dist/cjs/{credential.helpers-cf5b7e3c.js → credential.helpers-17a10601.js} +2 -2
  28. package/dist/cjs/{credential.helpers-cf5b7e3c.js.map → credential.helpers-17a10601.js.map} +1 -1
  29. package/dist/cjs/index-47efbf5b.js +1186 -0
  30. package/dist/cjs/index-47efbf5b.js.map +1 -0
  31. package/dist/cjs/index.js +24 -16
  32. package/dist/cjs/index.js.map +1 -1
  33. package/dist/cjs/index14.js +17 -14
  34. package/dist/cjs/index14.js.map +1 -1
  35. package/dist/cjs/index20.js +2 -1
  36. package/dist/cjs/index20.js.map +1 -1
  37. package/dist/cjs/index22.js +6 -6
  38. package/dist/cjs/index31.js +2 -1
  39. package/dist/cjs/index31.js.map +1 -1
  40. package/dist/cjs/index39.js +12 -11
  41. package/dist/cjs/index39.js.map +1 -1
  42. package/dist/cjs/index4.js +3 -3
  43. package/dist/cjs/index40.js +4 -4
  44. package/dist/cjs/index41.js +5 -5
  45. package/dist/cjs/index42.js +9 -7
  46. package/dist/cjs/index42.js.map +1 -1
  47. package/dist/cjs/index43.js +1 -1
  48. package/dist/cjs/index46.js +2 -2
  49. package/dist/cjs/index7.js +5 -5
  50. package/dist/cjs/{types.esm-e775534b.js → types.esm-73624b33.js} +12 -3
  51. package/dist/cjs/types.esm-73624b33.js.map +1 -0
  52. package/dist/esm/{BoostGenericCard-f0c9952c.js → BoostGenericCard-3b3ff784.js} +22 -3
  53. package/dist/esm/BoostGenericCard-3b3ff784.js.map +1 -0
  54. package/dist/esm/{CertificateDisplayCard-34e0b6a8.js → CertificateDisplayCard-f26ef278.js} +5 -5
  55. package/dist/esm/{CertificateDisplayCard-34e0b6a8.js.map → CertificateDisplayCard-f26ef278.js.map} +1 -1
  56. package/dist/esm/{Lightbox-f0338a0c.js → Lightbox-11f4888d.js} +95 -3
  57. package/dist/esm/Lightbox-11f4888d.js.map +1 -0
  58. package/dist/esm/{MeritBadgeDisplayCard-c6f65ac6.js → MeritBadgeDisplayCard-0d0b28c7.js} +5 -5
  59. package/dist/esm/{MeritBadgeDisplayCard-c6f65ac6.js.map → MeritBadgeDisplayCard-0d0b28c7.js.map} +1 -1
  60. package/dist/esm/{MeritBadgeSkillsCount-c51c5111.js → MeritBadgeSkillsCount-09e211fc.js} +2 -2
  61. package/dist/esm/{MeritBadgeSkillsCount-c51c5111.js.map → MeritBadgeSkillsCount-09e211fc.js.map} +1 -1
  62. package/dist/esm/{SchoolIDCard-f82e40bc.js → SchoolIDCard-fbd1cf6c.js} +2 -1183
  63. package/dist/esm/SchoolIDCard-fbd1cf6c.js.map +1 -0
  64. package/dist/esm/{VCCard-6c85ef2e.js → VCCard-7e5e00d7.js} +3 -3
  65. package/dist/esm/{VCCard-6c85ef2e.js.map → VCCard-7e5e00d7.js.map} +1 -1
  66. package/dist/esm/{VCDisplayBackFace-211f4bfb.js → VCDisplayBackFace-ae74476d.js} +3 -3
  67. package/dist/esm/{VCDisplayBackFace-211f4bfb.js.map → VCDisplayBackFace-ae74476d.js.map} +1 -1
  68. package/dist/esm/{VCDisplayCard-04660178.js → VCDisplayCard-ac1f367a.js} +3 -3
  69. package/dist/esm/{VCDisplayCard-04660178.js.map → VCDisplayCard-ac1f367a.js.map} +1 -1
  70. package/dist/esm/{VCDisplayCard2-d4594742.js → VCDisplayCard2-7011596e.js} +94 -70
  71. package/dist/esm/VCDisplayCard2-7011596e.js.map +1 -0
  72. package/dist/esm/{VCVerificationPill-eafbb82e.js → VCVerificationPill-04ffdf56.js} +2 -2
  73. package/dist/esm/{VCVerificationPill-eafbb82e.js.map → VCVerificationPill-04ffdf56.js.map} +1 -1
  74. package/dist/esm/{VerifierStateBadgeAndText-2859597e.js → VerifierStateBadgeAndText-750a6c24.js} +13 -4
  75. package/dist/{cjs/VerifierStateBadgeAndText-54065ad8.js.map → esm/VerifierStateBadgeAndText-750a6c24.js.map} +1 -1
  76. package/dist/esm/{credential.helpers-845e8c09.js → credential.helpers-4fbdd8e8.js} +2 -2
  77. package/dist/esm/{credential.helpers-845e8c09.js.map → credential.helpers-4fbdd8e8.js.map} +1 -1
  78. package/dist/esm/index-cbd2e9db.js +1184 -0
  79. package/dist/esm/index-cbd2e9db.js.map +1 -0
  80. package/dist/esm/index.js +16 -15
  81. package/dist/esm/index.js.map +1 -1
  82. package/dist/esm/index14.js +15 -14
  83. package/dist/esm/index14.js.map +1 -1
  84. package/dist/esm/index20.js +1 -1
  85. package/dist/esm/index22.js +6 -6
  86. package/dist/esm/index31.js +2 -1
  87. package/dist/esm/index31.js.map +1 -1
  88. package/dist/esm/index39.js +12 -11
  89. package/dist/esm/index39.js.map +1 -1
  90. package/dist/esm/index4.js +3 -3
  91. package/dist/esm/index40.js +4 -4
  92. package/dist/esm/index41.js +5 -5
  93. package/dist/esm/index42.js +8 -7
  94. package/dist/esm/index42.js.map +1 -1
  95. package/dist/esm/index43.js +1 -1
  96. package/dist/esm/index46.js +2 -2
  97. package/dist/esm/index7.js +5 -5
  98. package/dist/esm/{types.esm-5b5092be.js → types.esm-5f75bd4a.js} +12 -3
  99. package/dist/esm/types.esm-5f75bd4a.js.map +1 -0
  100. package/dist/index.d.ts +81 -9
  101. package/dist/main.css +1 -1
  102. package/dist/main.js +1 -1
  103. package/package.json +3 -3
  104. package/src/assets/styles/main.css +10 -0
  105. package/src/components/BoostGenericCard/BoostGenericCard.tsx +9 -2
  106. package/src/components/BoostGenericCard/__tests__/BoostGenericCard.thumb.test.tsx +59 -0
  107. package/src/components/Lightbox/ExternalVideoFallback.tsx +53 -0
  108. package/src/components/Lightbox/Lightbox.tsx +23 -9
  109. package/src/components/Lightbox/__tests__/Lightbox.video.test.tsx +139 -0
  110. package/src/components/Lightbox/index.ts +1 -0
  111. package/src/components/VCDisplayCard2/FitText.tsx +75 -46
  112. package/src/components/VCDisplayCard2/VCDisplayCard2.tsx +59 -19
  113. package/src/components/VCDisplayCard2/__tests__/FitText.test.tsx +140 -0
  114. package/src/components/VCDisplayCard2/__tests__/VCDisplayCard2Variant.test.ts +31 -0
  115. package/src/helpers/externalUrl.helpers.ts +51 -0
  116. package/src/helpers/video.helpers.ts +41 -0
  117. package/src/index.ts +5 -0
  118. package/dist/cjs/BoostGenericCard-feaf99bd.js.map +0 -1
  119. package/dist/cjs/Lightbox-d72e19ba.js.map +0 -1
  120. package/dist/cjs/SchoolIDCard-10796199.js.map +0 -1
  121. package/dist/cjs/VCDisplayCard2-81d04ca4.js.map +0 -1
  122. package/dist/cjs/types.esm-e775534b.js.map +0 -1
  123. package/dist/esm/BoostGenericCard-f0c9952c.js.map +0 -1
  124. package/dist/esm/Lightbox-f0338a0c.js.map +0 -1
  125. package/dist/esm/SchoolIDCard-f82e40bc.js.map +0 -1
  126. package/dist/esm/VCDisplayCard2-d4594742.js.map +0 -1
  127. package/dist/esm/types.esm-5b5092be.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/react",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "",
5
5
  "main": "dist/cjs",
6
6
  "module": "dist/esm",
@@ -24,7 +24,7 @@
24
24
  "author": "Learning Economy Foundation (www.learningeconomy.io)",
25
25
  "license": "MIT",
26
26
  "devDependencies": {
27
- "@learncard/types": "5.18.0",
27
+ "@learncard/types": "5.18.1",
28
28
  "@rollup/plugin-commonjs": "^22.0.0",
29
29
  "@rollup/plugin-json": "^4.1.0",
30
30
  "@rollup/plugin-node-resolve": "^13.3.0",
@@ -66,7 +66,7 @@
66
66
  "vite": "^6.4.2"
67
67
  },
68
68
  "dependencies": {
69
- "@learncard/init": "2.4.9",
69
+ "@learncard/init": "2.4.10",
70
70
  "date-fns": "^2.28.0",
71
71
  "numeral": "^2.0.6",
72
72
  "pretty-bytes": "^6.0.0",
@@ -371,6 +371,16 @@
371
371
  font-family: 'Mouse Memoirs';
372
372
  }
373
373
 
374
+ /*
375
+ * Ribbon tails extend 30px beyond the card. Keep that overhang plus an 8px
376
+ * visible gutter inside VCDisplayCard2 so every preview host gets the same fit.
377
+ */
378
+ .vc-display-card-ribbon-safe-area {
379
+ box-sizing: border-box;
380
+ /* Hosts that already reserve ribbon-tail space can set this custom property to 0px. */
381
+ padding-inline: var(--vc-display-card-ribbon-safe-area, 38px);
382
+ }
383
+
374
384
  .vc-toggle-side-button {
375
385
  background-color: #00000099;
376
386
  }
@@ -98,8 +98,15 @@ export const BoostGenericCard: React.FC<BoostGenericCardProps> = ({
98
98
  onClick={handleInnerClick}
99
99
  >
100
100
  {/* Thumbnail — filter on the wrapper so it desaturates a
101
- customThumbComponent too, not just the default thumb section. */}
102
- <div style={inactiveMediaStyle}>
101
+ customThumbComponent too, not just the default thumb section.
102
+ The explicit width is load-bearing on iOS 18 WebKit: a bare block
103
+ child of a <button> flex container is laid out shrink-to-fit there
104
+ (UA align-items: flex-start per the HTML spec), so without it the
105
+ badge collapses to its 116px circle and the header artwork stops
106
+ running under the options button. iOS 26+/desktop engines compute
107
+ align-items: normal and stretch, which masks the bug (LC-2066).
108
+ width is spread last so nothing can ever override it. */}
109
+ <div style={{ ...inactiveMediaStyle, width: '100%' }}>
103
110
  {customThumbComponent || (
104
111
  <section className={defaultThumbClass}>
105
112
  {thumbImgSrc?.trim() ? (
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import React from 'react';
4
+ import { render } from '@testing-library/react';
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ import { BoostGenericCard } from '../BoostGenericCard';
8
+
9
+ /**
10
+ * LC-2066 regression guard.
11
+ *
12
+ * The thumbnail wrapper added for the revoked/suspended grayscale treatment
13
+ * (#1366) sits directly inside the card's `<button>`. On iOS 18 WebKit a bare
14
+ * block child of a <button> flex container is laid out shrink-to-fit (UA
15
+ * `align-items: flex-start` per the HTML spec), so the badge — which sizes
16
+ * itself with `width: 100%` — collapses to the width of its 116px circle and
17
+ * the header artwork no longer runs under the absolutely-positioned options
18
+ * ("...") button. iOS 26+/desktop engines compute `align-items: normal` and
19
+ * stretch the wrapper, which masks the bug. The wrapper must therefore carry
20
+ * its own explicit width. Verified by A/B on an iOS 18.1 simulator:
21
+ * without width the wrapper measures 116px, with it 160px.
22
+ */
23
+ describe('BoostGenericCard thumbnail wrapper', () => {
24
+ const getThumbWrapper = (container: HTMLElement) => {
25
+ const badge = container.querySelector('[data-testid="thumb-child"]');
26
+ expect(badge).not.toBeNull();
27
+
28
+ return badge!.parentElement as HTMLElement;
29
+ };
30
+
31
+ test('gives the custom thumbnail wrapper an explicit full width', () => {
32
+ const { container } = render(
33
+ <BoostGenericCard
34
+ title="Camp Counselor"
35
+ customThumbComponent={<div data-testid="thumb-child" style={{ width: '100%' }} />}
36
+ optionsTriggerOnClick={() => {}}
37
+ />
38
+ );
39
+
40
+ expect(getThumbWrapper(container).style.width).toBe('100%');
41
+ });
42
+
43
+ test('keeps the explicit width when the credential is revoked', () => {
44
+ const { container } = render(
45
+ <BoostGenericCard
46
+ title="Camp Counselor"
47
+ customThumbComponent={<div data-testid="thumb-child" style={{ width: '100%' }} />}
48
+ optionsTriggerOnClick={() => {}}
49
+ lifecycleStatus="revoked"
50
+ />
51
+ );
52
+
53
+ const wrapper = getThumbWrapper(container);
54
+
55
+ expect(wrapper.style.width).toBe('100%');
56
+ // The grayscale treatment must survive alongside the width.
57
+ expect(wrapper.style.filter).toContain('grayscale');
58
+ });
59
+ });
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+
3
+ import { openExternalUrl } from '../../helpers/externalUrl.helpers';
4
+
5
+ export type ExternalVideoFallbackProps = {
6
+ /** Public `watch` URL opened when the user taps play. */
7
+ url: string;
8
+ /** Poster frame, when we managed to resolve one. */
9
+ thumbnailUrl?: string | null;
10
+ label?: string;
11
+ className?: string;
12
+ };
13
+
14
+ /**
15
+ * Stand-in for a video `<iframe>` in documents where third-party embeds cannot
16
+ * play — see `canEmbedVideoIframe`. Renders the poster frame with a play button
17
+ * that hands playback off to an external browser.
18
+ */
19
+ export const ExternalVideoFallback: React.FC<ExternalVideoFallbackProps> = ({
20
+ url,
21
+ thumbnailUrl,
22
+ label = 'Watch on YouTube',
23
+ className = '',
24
+ }) => (
25
+ <button
26
+ type="button"
27
+ onClick={e => {
28
+ e.stopPropagation();
29
+ openExternalUrl(url);
30
+ }}
31
+ aria-label={label}
32
+ className={`relative w-full h-full flex flex-col items-center justify-center gap-[16px] overflow-hidden rounded-md bg-black ${className}`}
33
+ >
34
+ {thumbnailUrl && (
35
+ <img
36
+ src={thumbnailUrl}
37
+ alt=""
38
+ aria-hidden="true"
39
+ className="absolute inset-0 w-full h-full object-cover opacity-60"
40
+ />
41
+ )}
42
+
43
+ <div className="relative w-[64px] h-[64px] rounded-full bg-white/95 flex items-center justify-center">
44
+ <svg viewBox="0 0 24 24" className="w-[28px] h-[28px] ml-[4px]" aria-hidden="true">
45
+ <path d="M8 5v14l11-7z" fill="#000" />
46
+ </svg>
47
+ </div>
48
+
49
+ <span className="relative text-white text-base font-semibold">{label}</span>
50
+ </button>
51
+ );
52
+
53
+ export default ExternalVideoFallback;
@@ -3,7 +3,13 @@ import { createPortal } from 'react-dom';
3
3
 
4
4
  import X from '../svgs/X';
5
5
 
6
- import { getVideoMetadata, VideoMetadata } from '../../helpers/video.helpers';
6
+ import {
7
+ canEmbedVideoIframe,
8
+ getExternalVideoUrl,
9
+ getVideoMetadata,
10
+ VideoMetadata,
11
+ } from '../../helpers/video.helpers';
12
+ import ExternalVideoFallback from './ExternalVideoFallback';
7
13
 
8
14
  export type LightboxItemType = 'photo' | 'video';
9
15
 
@@ -117,14 +123,22 @@ export const Lightbox: React.FC<LightboxProps> = ({
117
123
 
118
124
  {currentItem.type === 'video' && videoMetadata?.embedUrl && (
119
125
  <div className="relative w-[90vw] max-w-[800px] aspect-video">
120
- <iframe
121
- src={videoMetadata.embedUrl}
122
- className="absolute top-0 left-0 w-full h-full rounded-md"
123
- title="Video player"
124
- frameBorder="0"
125
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
126
- allowFullScreen
127
- />
126
+ {canEmbedVideoIframe(videoMetadata.type) ? (
127
+ <iframe
128
+ src={videoMetadata.embedUrl}
129
+ className="absolute top-0 left-0 w-full h-full rounded-md"
130
+ title="Video player"
131
+ frameBorder="0"
132
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
133
+ allowFullScreen
134
+ />
135
+ ) : (
136
+ <ExternalVideoFallback
137
+ url={getExternalVideoUrl(videoMetadata, currentItem.url)}
138
+ thumbnailUrl={videoMetadata.thumbnailUrl}
139
+ className="absolute top-0 left-0"
140
+ />
141
+ )}
128
142
  </div>
129
143
  )}
130
144
  </div>,
@@ -0,0 +1,139 @@
1
+ import React from 'react';
2
+ import '@testing-library/jest-dom';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+
6
+ import { Lightbox } from '../Lightbox';
7
+ import { openExternalUrl, setExternalUrlOpener } from '../../../helpers/externalUrl.helpers';
8
+ import { canEmbedVideoIframe, getExternalVideoUrl } from '../../../helpers/video.helpers';
9
+
10
+ const VIDEO_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
11
+
12
+ const setProtocol = (protocol: string) => {
13
+ Object.defineProperty(window, 'location', {
14
+ configurable: true,
15
+ writable: true,
16
+ value: { ...window.location, protocol },
17
+ });
18
+ };
19
+
20
+ const renderLightbox = () =>
21
+ render(
22
+ <Lightbox
23
+ items={[{ url: VIDEO_URL, type: 'video' }]}
24
+ currentUrl={VIDEO_URL}
25
+ setCurrentUrl={() => undefined}
26
+ />
27
+ );
28
+
29
+ describe('canEmbedVideoIframe', () => {
30
+ afterEach(() => setProtocol('http:'));
31
+
32
+ it('allows iframe embeds on http(s) documents', () => {
33
+ setProtocol('https:');
34
+ expect(canEmbedVideoIframe()).toBe(true);
35
+
36
+ setProtocol('http:');
37
+ expect(canEmbedVideoIframe()).toBe(true);
38
+ });
39
+
40
+ it("blocks iframe embeds on Capacitor iOS's custom scheme", () => {
41
+ // WKWebView cannot send `capacitor://localhost` as an HTTP Referer, so
42
+ // YouTube rejects the embed with "Error 153".
43
+ setProtocol('capacitor:');
44
+ expect(canEmbedVideoIframe()).toBe(false);
45
+ expect(canEmbedVideoIframe('youtube')).toBe(false);
46
+ });
47
+
48
+ it('leaves non-YouTube platforms embedded — only YouTube checks the referrer', () => {
49
+ setProtocol('capacitor:');
50
+ expect(canEmbedVideoIframe('vimeo')).toBe(true);
51
+ expect(canEmbedVideoIframe('drive')).toBe(true);
52
+ expect(canEmbedVideoIframe('loom')).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('openExternalUrl', () => {
57
+ afterEach(() => setExternalUrlOpener(undefined));
58
+
59
+ it('refuses non-web schemes from untrusted attachment data', async () => {
60
+ const opened: string[] = [];
61
+ setExternalUrlOpener(url => {
62
+ opened.push(url);
63
+ });
64
+
65
+ await openExternalUrl('javascript:alert(1)');
66
+ await openExternalUrl('data:text/html,<script>alert(1)</script>');
67
+ await openExternalUrl('');
68
+ expect(opened).toEqual([]);
69
+
70
+ await openExternalUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
71
+ expect(opened).toEqual(['https://www.youtube.com/watch?v=dQw4w9WgXcQ']);
72
+ });
73
+ });
74
+
75
+ describe('getExternalVideoUrl', () => {
76
+ it('builds a public watch URL from YouTube metadata', () => {
77
+ expect(
78
+ getExternalVideoUrl(
79
+ {
80
+ type: 'youtube',
81
+ videoId: 'dQw4w9WgXcQ',
82
+ embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ',
83
+ thumbnailUrl: null,
84
+ },
85
+ 'fallback'
86
+ )
87
+ ).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
88
+ });
89
+
90
+ it('falls back to the original URL for non-YouTube videos', () => {
91
+ expect(
92
+ getExternalVideoUrl(
93
+ {
94
+ type: 'vimeo',
95
+ videoId: '123',
96
+ embedUrl: 'https://player.vimeo.com/video/123',
97
+ thumbnailUrl: null,
98
+ },
99
+ 'https://vimeo.com/123'
100
+ )
101
+ ).toBe('https://vimeo.com/123');
102
+ });
103
+ });
104
+
105
+ describe('Lightbox video playback', () => {
106
+ afterEach(() => {
107
+ setProtocol('http:');
108
+ setExternalUrlOpener(undefined);
109
+ });
110
+
111
+ it('renders an inline iframe on the web', async () => {
112
+ setProtocol('https:');
113
+ renderLightbox();
114
+
115
+ // Lightbox renders through a portal into document.body
116
+ await waitFor(() => expect(document.body.querySelector('iframe')).toBeInTheDocument());
117
+ expect(document.body.querySelector('iframe')).toHaveAttribute(
118
+ 'src',
119
+ 'https://www.youtube.com/embed/dQw4w9WgXcQ'
120
+ );
121
+ });
122
+
123
+ it('hands playback to an external browser when embeds cannot work', async () => {
124
+ setProtocol('capacitor:');
125
+ const opened: string[] = [];
126
+ setExternalUrlOpener(url => {
127
+ opened.push(url);
128
+ });
129
+
130
+ renderLightbox();
131
+
132
+ const button = await screen.findByRole('button', { name: 'Watch on YouTube' });
133
+ expect(document.body.querySelector('iframe')).not.toBeInTheDocument();
134
+
135
+ await userEvent.click(button);
136
+
137
+ expect(opened).toEqual(['https://www.youtube.com/watch?v=dQw4w9WgXcQ']);
138
+ });
139
+ });
@@ -2,4 +2,5 @@
2
2
  * @file Automatically generated by barrelsby.
3
3
  */
4
4
 
5
+ export * from './ExternalVideoFallback';
5
6
  export * from './Lightbox';
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef } from 'react';
1
+ import React, { useLayoutEffect, useRef } from 'react';
2
2
 
3
3
  interface FitTextProps {
4
4
  text: string;
@@ -8,6 +8,14 @@ interface FitTextProps {
8
8
  maxFontSize?: number;
9
9
  }
10
10
 
11
+ // Keep fitted text slightly inside the measured edge to account for borders,
12
+ // subpixel rounding, and glyph overhang on narrow screens.
13
+ const FIT_GUTTER_PX = 12;
14
+
15
+ /**
16
+ * Shrinks a single-line label to fit its container, wrapping only when the
17
+ * text still cannot fit at the configured minimum font size.
18
+ */
11
19
  const FitText: React.FC<FitTextProps> = ({
12
20
  text,
13
21
  width,
@@ -15,67 +23,88 @@ const FitText: React.FC<FitTextProps> = ({
15
23
  minFontSize = 10,
16
24
  maxFontSize = 100,
17
25
  }) => {
18
- const textRef = useRef<HTMLDivElement>(null);
19
- let animationFrameId: number | null = null;
26
+ const containerRef = useRef<HTMLDivElement>(null);
27
+ const textRef = useRef<HTMLSpanElement>(null);
20
28
 
21
- const adjustFontSize = () => {
22
- if (textRef.current) {
23
- const currentFontSize = parseFloat(
24
- window.getComputedStyle(textRef.current).getPropertyValue('font-size')
25
- );
29
+ useLayoutEffect(() => {
30
+ const container = containerRef.current;
31
+ const textElement = textRef.current;
32
+ if (!container || !textElement) return;
33
+
34
+ const fitText = () => {
35
+ // Measure from the maximum size each time so repeated resizes do not
36
+ // compound a previously calculated font size. Font-size transitions
37
+ // are intentionally avoided so scrollWidth reflects this size immediately.
38
+ textElement.style.fontSize = `${maxFontSize}px`;
39
+ textElement.style.whiteSpace = 'nowrap';
26
40
 
27
- // Need to calculate spacing based on nowrap to prevent thrashing
28
- textRef.current.style.whiteSpace = 'nowrap';
29
- const parentWidth = (textRef.current.parentNode as any)?.clientWidth;
30
- const scrollWidth = textRef.current.scrollWidth || textRef.current.offsetWidth;
41
+ const availableWidth = Math.max(container.clientWidth - FIT_GUTTER_PX, 0);
42
+ const requiredWidth = textElement.scrollWidth;
43
+ if (!availableWidth || !requiredWidth) return;
31
44
 
32
- // Sometimes scrollWidth can temporarily be 0. If so, just try again next frame
33
- if (scrollWidth === 0) {
34
- if (animationFrameId !== null) {
35
- cancelAnimationFrame(animationFrameId);
36
- }
45
+ let fittedFontSize = Math.min(
46
+ maxFontSize,
47
+ Math.max(minFontSize, Math.floor((availableWidth / requiredWidth) * maxFontSize))
48
+ );
37
49
 
38
- animationFrameId = requestAnimationFrame(adjustFontSize);
50
+ textElement.style.fontSize = `${fittedFontSize}px`;
39
51
 
40
- return;
52
+ // Font metrics and browser rounding are not perfectly linear. Verify
53
+ // the estimate so a one-pixel overflow cannot silently clip a title.
54
+ while (textElement.scrollWidth > availableWidth && fittedFontSize > minFontSize) {
55
+ fittedFontSize -= 1;
56
+ textElement.style.fontSize = `${fittedFontSize}px`;
41
57
  }
42
58
 
43
- const newFontSize = Math.min(
44
- Math.max((parentWidth / scrollWidth) * currentFontSize, minFontSize),
45
- maxFontSize
46
- );
59
+ // Preserve the single-line ribbon treatment whenever possible.
60
+ // Extremely long titles may wrap only after reaching the minimum.
61
+ textElement.style.whiteSpace =
62
+ fittedFontSize === minFontSize && textElement.scrollWidth > availableWidth
63
+ ? 'normal'
64
+ : 'nowrap';
65
+ };
47
66
 
48
- textRef.current.style.fontSize = `${newFontSize}px`;
49
- textRef.current.style.whiteSpace = newFontSize === minFontSize ? 'normal' : 'nowrap';
50
- }
51
- };
67
+ fitText();
52
68
 
53
- const handleResize = () => {
54
- if (animationFrameId !== null) {
55
- cancelAnimationFrame(animationFrameId);
56
- }
69
+ // Device rotation and responsive layouts change the container without
70
+ // necessarily changing the text or receiving a window resize event.
71
+ let resizeObserver: ResizeObserver | undefined;
72
+ if (typeof ResizeObserver !== 'undefined') {
73
+ let lastObservedWidth: number | undefined;
74
+ resizeObserver = new ResizeObserver(([entry]) => {
75
+ const observedWidth = entry?.contentRect.width;
57
76
 
58
- animationFrameId = requestAnimationFrame(adjustFontSize);
59
- };
77
+ if (observedWidth === undefined || observedWidth === lastObservedWidth) return;
60
78
 
61
- useEffect(() => {
62
- window.addEventListener('resize', handleResize);
63
- adjustFontSize();
79
+ lastObservedWidth = observedWidth;
80
+ fitText();
81
+ });
82
+ resizeObserver.observe(container);
83
+ }
84
+
85
+ // Web fonts can alter the measured glyph width after the first layout.
86
+ let isMounted = true;
87
+ document.fonts?.ready
88
+ .then(() => {
89
+ if (isMounted) fitText();
90
+ })
91
+ .catch(() => {
92
+ // The initial synchronous measurement remains a safe fallback.
93
+ });
64
94
 
65
95
  return () => {
66
- window.removeEventListener('resize', handleResize);
67
- if (animationFrameId !== null) {
68
- cancelAnimationFrame(animationFrameId);
69
- }
96
+ isMounted = false;
97
+ resizeObserver?.disconnect();
70
98
  };
71
- }, [text]);
99
+ }, [maxFontSize, minFontSize, text, width]);
72
100
 
73
101
  return (
74
- <div style={{ width }} className={`text-center ${className}`}>
75
- <span
76
- className={`text-[${minFontSize}px] transition-[font-size] whitespace-nowrap`}
77
- ref={textRef}
78
- >
102
+ <div
103
+ ref={containerRef}
104
+ style={{ width, maxWidth: '100%' }}
105
+ className={`text-center ${className}`}
106
+ >
107
+ <span className="inline-block max-w-full whitespace-nowrap" ref={textRef}>
79
108
  {text}
80
109
  </span>
81
110
  </div>