@learncard/react 2.10.8 → 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 (164) hide show
  1. package/dist/base.css +1 -1
  2. package/dist/base.js +1 -1
  3. package/dist/cjs/{BoostGenericCard-99a3ab28.js → BoostGenericCard-71899305.js} +66 -7
  4. package/dist/cjs/BoostGenericCard-71899305.js.map +1 -0
  5. package/dist/cjs/{CertificateDisplayCard-b673e66d.js → CertificateDisplayCard-f10b46b1.js} +5 -5
  6. package/dist/cjs/{CertificateDisplayCard-b673e66d.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-15b85a79.js → MeritBadgeDisplayCard-d50cfb5e.js} +5 -5
  10. package/dist/cjs/{MeritBadgeDisplayCard-15b85a79.js.map → MeritBadgeDisplayCard-d50cfb5e.js.map} +1 -1
  11. package/dist/cjs/{MeritBadgeSkillsCount-f62ad753.js → MeritBadgeSkillsCount-e46503f5.js} +2 -2
  12. package/dist/cjs/{MeritBadgeSkillsCount-f62ad753.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-4f02e2f4.js → VCCard-42479c97.js} +3 -3
  16. package/dist/cjs/{VCCard-4f02e2f4.js.map → VCCard-42479c97.js.map} +1 -1
  17. package/dist/cjs/{VCDisplayBackFace-aab4d52c.js → VCDisplayBackFace-d1cf9f73.js} +3 -3
  18. package/dist/cjs/{VCDisplayBackFace-aab4d52c.js.map → VCDisplayBackFace-d1cf9f73.js.map} +1 -1
  19. package/dist/cjs/{VCDisplayCard-95b7f7dc.js → VCDisplayCard-cf46c224.js} +3 -3
  20. package/dist/cjs/{VCDisplayCard-95b7f7dc.js.map → VCDisplayCard-cf46c224.js.map} +1 -1
  21. package/dist/cjs/{VCDisplayCard2-8517f419.js → VCDisplayCard2-4a201a8a.js} +95 -69
  22. package/dist/cjs/VCDisplayCard2-4a201a8a.js.map +1 -0
  23. package/dist/cjs/{VCVerificationPill-b5824def.js → VCVerificationPill-6c9dcf80.js} +2 -2
  24. package/dist/cjs/{VCVerificationPill-b5824def.js.map → VCVerificationPill-6c9dcf80.js.map} +1 -1
  25. package/dist/cjs/{VerifierStateBadgeAndText-c15064b2.js → VerifierStateBadgeAndText-934239ab.js} +134 -6
  26. package/dist/cjs/VerifierStateBadgeAndText-934239ab.js.map +1 -0
  27. package/dist/cjs/{credential.helpers-98fc74d8.js → credential.helpers-17a10601.js} +2 -2
  28. package/dist/cjs/{credential.helpers-98fc74d8.js.map → credential.helpers-17a10601.js.map} +1 -1
  29. package/dist/cjs/{i18n-cc7e34ec.js → i18n-8fd90b2f.js} +5 -2
  30. package/dist/cjs/i18n-8fd90b2f.js.map +1 -0
  31. package/dist/cjs/index-47efbf5b.js +1186 -0
  32. package/dist/cjs/index-47efbf5b.js.map +1 -0
  33. package/dist/cjs/index-5488badc.js.map +1 -1
  34. package/dist/cjs/index.js +28 -19
  35. package/dist/cjs/index.js.map +1 -1
  36. package/dist/cjs/index14.js +18 -15
  37. package/dist/cjs/index14.js.map +1 -1
  38. package/dist/cjs/index20.js +2 -1
  39. package/dist/cjs/index20.js.map +1 -1
  40. package/dist/cjs/index22.js +7 -7
  41. package/dist/cjs/index31.js +2 -1
  42. package/dist/cjs/index31.js.map +1 -1
  43. package/dist/cjs/index39.js +13 -12
  44. package/dist/cjs/index39.js.map +1 -1
  45. package/dist/cjs/index4.js +4 -3
  46. package/dist/cjs/index4.js.map +1 -1
  47. package/dist/cjs/index40.js +4 -4
  48. package/dist/cjs/index41.js +5 -5
  49. package/dist/cjs/index42.js +10 -8
  50. package/dist/cjs/index42.js.map +1 -1
  51. package/dist/cjs/index43.js +1 -1
  52. package/dist/cjs/index46.js +2 -2
  53. package/dist/cjs/index7.js +5 -5
  54. package/dist/cjs/{types.esm-f1680b31.js → types.esm-73624b33.js} +105 -4
  55. package/dist/cjs/types.esm-73624b33.js.map +1 -0
  56. package/dist/esm/{AchievementCard-c600788a.js → AchievementCard-0f183e9d.js} +2 -2
  57. package/dist/esm/{AchievementCard-c600788a.js.map → AchievementCard-0f183e9d.js.map} +1 -1
  58. package/dist/esm/{BoostGenericCard-7dfcc887.js → BoostGenericCard-3b3ff784.js} +67 -9
  59. package/dist/esm/BoostGenericCard-3b3ff784.js.map +1 -0
  60. package/dist/esm/{BoostSmallCard-a73b6800.js → BoostSmallCard-3dedb5ae.js} +2 -2
  61. package/dist/esm/{BoostSmallCard-a73b6800.js.map → BoostSmallCard-3dedb5ae.js.map} +1 -1
  62. package/dist/esm/{CertificateDisplayCard-4e9254e0.js → CertificateDisplayCard-f26ef278.js} +5 -5
  63. package/dist/esm/{CertificateDisplayCard-4e9254e0.js.map → CertificateDisplayCard-f26ef278.js.map} +1 -1
  64. package/dist/esm/{GenericCard-a469f2f0.js → GenericCard-b40c0617.js} +2 -2
  65. package/dist/esm/{GenericCard-a469f2f0.js.map → GenericCard-b40c0617.js.map} +1 -1
  66. package/dist/esm/{JobListCard-e8b4ab33.js → JobListCard-dfb94830.js} +2 -2
  67. package/dist/esm/{JobListCard-e8b4ab33.js.map → JobListCard-dfb94830.js.map} +1 -1
  68. package/dist/esm/{LearnPill-e0720485.js → LearnPill-ce240a4a.js} +2 -2
  69. package/dist/esm/{LearnPill-e0720485.js.map → LearnPill-ce240a4a.js.map} +1 -1
  70. package/dist/esm/{Lightbox-f0338a0c.js → Lightbox-11f4888d.js} +95 -3
  71. package/dist/esm/Lightbox-11f4888d.js.map +1 -0
  72. package/dist/esm/{MeritBadgeDisplayCard-2029a552.js → MeritBadgeDisplayCard-0d0b28c7.js} +5 -5
  73. package/dist/esm/{MeritBadgeDisplayCard-2029a552.js.map → MeritBadgeDisplayCard-0d0b28c7.js.map} +1 -1
  74. package/dist/esm/{MeritBadgeSkillsCount-c5271d7f.js → MeritBadgeSkillsCount-09e211fc.js} +2 -2
  75. package/dist/esm/{MeritBadgeSkillsCount-c5271d7f.js.map → MeritBadgeSkillsCount-09e211fc.js.map} +1 -1
  76. package/dist/esm/{SchoolIDCard-f82e40bc.js → SchoolIDCard-fbd1cf6c.js} +2 -1183
  77. package/dist/esm/SchoolIDCard-fbd1cf6c.js.map +1 -0
  78. package/dist/esm/{VCCard-c0f6dff3.js → VCCard-7e5e00d7.js} +3 -3
  79. package/dist/esm/{VCCard-c0f6dff3.js.map → VCCard-7e5e00d7.js.map} +1 -1
  80. package/dist/esm/{VCDisplayBackFace-bb60a881.js → VCDisplayBackFace-ae74476d.js} +3 -3
  81. package/dist/esm/{VCDisplayBackFace-bb60a881.js.map → VCDisplayBackFace-ae74476d.js.map} +1 -1
  82. package/dist/esm/{VCDisplayCard-9905d9d3.js → VCDisplayCard-ac1f367a.js} +3 -3
  83. package/dist/esm/{VCDisplayCard-9905d9d3.js.map → VCDisplayCard-ac1f367a.js.map} +1 -1
  84. package/dist/esm/{VCDisplayCard2-fc5c4225.js → VCDisplayCard2-7011596e.js} +96 -71
  85. package/dist/esm/VCDisplayCard2-7011596e.js.map +1 -0
  86. package/dist/esm/{VCVerificationPill-83eb61f4.js → VCVerificationPill-04ffdf56.js} +2 -2
  87. package/dist/esm/{VCVerificationPill-83eb61f4.js.map → VCVerificationPill-04ffdf56.js.map} +1 -1
  88. package/dist/esm/{VerifierStateBadgeAndText-60b98d25.js → VerifierStateBadgeAndText-750a6c24.js} +134 -6
  89. package/dist/esm/VerifierStateBadgeAndText-750a6c24.js.map +1 -0
  90. package/dist/esm/{constants-f1ed610c.js → constants-010faf74.js} +2 -2
  91. package/dist/esm/{constants-f1ed610c.js.map → constants-010faf74.js.map} +1 -1
  92. package/dist/esm/{credential.helpers-00099c70.js → credential.helpers-4fbdd8e8.js} +2 -2
  93. package/dist/esm/{credential.helpers-00099c70.js.map → credential.helpers-4fbdd8e8.js.map} +1 -1
  94. package/dist/esm/{i18n-8454d6ea.js → i18n-08b606cb.js} +5 -2
  95. package/dist/esm/i18n-08b606cb.js.map +1 -0
  96. package/dist/esm/index-28917993.js.map +1 -1
  97. package/dist/esm/{index-cbe3a76a.js → index-7542e9ee.js} +2 -2
  98. package/dist/esm/{index-cbe3a76a.js.map → index-7542e9ee.js.map} +1 -1
  99. package/dist/esm/index-cbd2e9db.js +1184 -0
  100. package/dist/esm/index-cbd2e9db.js.map +1 -0
  101. package/dist/esm/index.js +25 -24
  102. package/dist/esm/index.js.map +1 -1
  103. package/dist/esm/index13.js +2 -2
  104. package/dist/esm/index14.js +23 -22
  105. package/dist/esm/index14.js.map +1 -1
  106. package/dist/esm/index16.js +2 -2
  107. package/dist/esm/index19.js +2 -2
  108. package/dist/esm/index2.js +2 -2
  109. package/dist/esm/index20.js +1 -1
  110. package/dist/esm/index22.js +7 -7
  111. package/dist/esm/index30.js +2 -2
  112. package/dist/esm/index31.js +2 -1
  113. package/dist/esm/index31.js.map +1 -1
  114. package/dist/esm/index39.js +13 -12
  115. package/dist/esm/index39.js.map +1 -1
  116. package/dist/esm/index4.js +5 -4
  117. package/dist/esm/index4.js.map +1 -1
  118. package/dist/esm/index40.js +4 -4
  119. package/dist/esm/index41.js +5 -5
  120. package/dist/esm/index42.js +9 -8
  121. package/dist/esm/index42.js.map +1 -1
  122. package/dist/esm/index43.js +1 -1
  123. package/dist/esm/index46.js +2 -2
  124. package/dist/esm/index5.js +2 -2
  125. package/dist/esm/index7.js +5 -5
  126. package/dist/esm/{types.esm-d6b48609.js → types.esm-5f75bd4a.js} +105 -4
  127. package/dist/esm/types.esm-5f75bd4a.js.map +1 -0
  128. package/dist/index.d.ts +104 -10
  129. package/dist/main.css +1 -1
  130. package/dist/main.js +1 -1
  131. package/package.json +3 -3
  132. package/src/assets/styles/main.css +10 -0
  133. package/src/components/BoostGenericCard/BoostGenericCard.tsx +60 -19
  134. package/src/components/BoostGenericCard/__tests__/BoostGenericCard.thumb.test.tsx +59 -0
  135. package/src/components/Lightbox/ExternalVideoFallback.tsx +53 -0
  136. package/src/components/Lightbox/Lightbox.tsx +23 -9
  137. package/src/components/Lightbox/__tests__/Lightbox.video.test.tsx +139 -0
  138. package/src/components/Lightbox/index.ts +1 -0
  139. package/src/components/VCDisplayCard2/FitText.tsx +75 -46
  140. package/src/components/VCDisplayCard2/VCDisplayCard2.tsx +59 -19
  141. package/src/components/VCDisplayCard2/VerificationRow.tsx +1 -0
  142. package/src/components/VCDisplayCard2/__tests__/FitText.test.tsx +140 -0
  143. package/src/components/VCDisplayCard2/__tests__/VCDisplayCard2Variant.test.ts +31 -0
  144. package/src/helpers/externalUrl.helpers.ts +51 -0
  145. package/src/helpers/lifecycle.helpers.ts +38 -0
  146. package/src/helpers/video.helpers.ts +41 -0
  147. package/src/i18n/index.test.ts +10 -0
  148. package/src/i18n/index.tsx +3 -0
  149. package/src/index.ts +8 -0
  150. package/src/types/index.ts +1 -0
  151. package/dist/cjs/BoostGenericCard-99a3ab28.js.map +0 -1
  152. package/dist/cjs/Lightbox-d72e19ba.js.map +0 -1
  153. package/dist/cjs/SchoolIDCard-10796199.js.map +0 -1
  154. package/dist/cjs/VCDisplayCard2-8517f419.js.map +0 -1
  155. package/dist/cjs/VerifierStateBadgeAndText-c15064b2.js.map +0 -1
  156. package/dist/cjs/i18n-cc7e34ec.js.map +0 -1
  157. package/dist/cjs/types.esm-f1680b31.js.map +0 -1
  158. package/dist/esm/BoostGenericCard-7dfcc887.js.map +0 -1
  159. package/dist/esm/Lightbox-f0338a0c.js.map +0 -1
  160. package/dist/esm/SchoolIDCard-f82e40bc.js.map +0 -1
  161. package/dist/esm/VCDisplayCard2-fc5c4225.js.map +0 -1
  162. package/dist/esm/VerifierStateBadgeAndText-60b98d25.js.map +0 -1
  163. package/dist/esm/i18n-8454d6ea.js.map +0 -1
  164. package/dist/esm/types.esm-d6b48609.js.map +0 -1
@@ -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>
@@ -43,6 +43,32 @@ export type CredentialIconType = {
43
43
  color?: string;
44
44
  };
45
45
 
46
+ export type VCDisplayCardVariant = 'ribbon' | 'award' | 'certificate' | 'id';
47
+
48
+ /**
49
+ * Returns the visual branch VCDisplayCard2 will render. Preview hosts use this
50
+ * to preserve their specialized-card spacing while the ribbon card owns its
51
+ * wider horizontal safe area.
52
+ */
53
+ export const getVCDisplayCardVariant = (
54
+ credential?: VC | BoostAchievementCredential,
55
+ categoryType?: LCCategoryEnum | string,
56
+ formattedDisplayType?: string
57
+ ): VCDisplayCardVariant => {
58
+ const resolvedDisplayType = (
59
+ credential?.display?.displayType ?? formattedDisplayType
60
+ )?.toLocaleLowerCase();
61
+
62
+ if (categoryType === LCCategoryEnum.meritBadge || resolvedDisplayType === 'award') {
63
+ return 'award';
64
+ }
65
+
66
+ if (resolvedDisplayType === 'certificate') return 'certificate';
67
+ if (resolvedDisplayType === 'id' || categoryType === LCCategoryEnum.id) return 'id';
68
+
69
+ return 'ribbon';
70
+ };
71
+
46
72
  export type VCDisplayCard2Props = {
47
73
  categoryType?: LCCategoryEnum;
48
74
  credential: VC | BoostAchievementCredential;
@@ -157,19 +183,30 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
157
183
  const isFront = isFrontOverride ?? _isFront;
158
184
  const setIsFront = setIsFrontOverride ?? _setIsFront;
159
185
 
160
- const [headerHeight, setHeaderHeight] = useState(100); // 79 is the height if the header is one line
161
- const [headerWidth, setHeaderWidth] = useState(0);
186
+ const [headerHeight, setHeaderHeight] = useState(79);
162
187
 
163
188
  const headerRef = useRef<HTMLHeadingElement>(null);
164
189
 
165
190
  useLayoutEffect(() => {
166
- // Needs a small setTimeout otherwise it'll be wrong sometimes with multiline header.
167
- // Probably because of the interaction with FitText
168
- setTimeout(() => {
169
- setHeaderHeight(headerRef.current?.clientHeight || 100);
170
- setHeaderWidth(headerRef.current?.clientWidth ?? 0);
171
- }, 10);
172
- });
191
+ const header = headerRef.current;
192
+ if (!header) return;
193
+
194
+ // Keep the ribbon tails aligned when a long title makes the center
195
+ // section taller than its normal single-line height.
196
+ const updateHeaderHeight = () => setHeaderHeight(header.clientHeight || 79);
197
+
198
+ updateHeaderHeight();
199
+ if (typeof ResizeObserver === 'undefined') return;
200
+
201
+ const resizeObserver = new ResizeObserver(updateHeaderHeight);
202
+ resizeObserver.observe(header);
203
+
204
+ return () => resizeObserver.disconnect();
205
+ }, []);
206
+
207
+ // The tails start 10px below the center ribbon and visually overlap its
208
+ // lower border, so they are slightly shorter than the measured header.
209
+ const ribbonEndHeight = Math.max(headerHeight - 4, 75).toString();
173
210
 
174
211
  let worstVerificationStatus = verificationItems.reduce(
175
212
  (
@@ -204,10 +241,13 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
204
241
 
205
242
  const _title = titleOverride || title;
206
243
 
207
- const resolvedDisplayType =
208
- credential?.display?.displayType ?? formattedDisplayType?.toLocaleLowerCase();
244
+ const displayCardVariant = getVCDisplayCardVariant(
245
+ credential,
246
+ categoryType,
247
+ formattedDisplayType
248
+ );
209
249
 
210
- if (categoryType === LCCategoryEnum.meritBadge || resolvedDisplayType === 'award') {
250
+ if (displayCardVariant === 'award') {
211
251
  return (
212
252
  <MeritBadgeDisplayCard
213
253
  credential={credential}
@@ -241,7 +281,7 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
241
281
  );
242
282
  }
243
283
 
244
- if (resolvedDisplayType === 'certificate') {
284
+ if (displayCardVariant === 'certificate') {
245
285
  return (
246
286
  <CertificateDisplayCard
247
287
  credential={credential}
@@ -273,7 +313,7 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
273
313
  onVerifierClick={onVerifierClick}
274
314
  />
275
315
  );
276
- } else if (resolvedDisplayType === 'id' || categoryType === 'ID') {
316
+ } else if (displayCardVariant === 'id') {
277
317
  return (
278
318
  <div>
279
319
  <VCIDDisplayCard
@@ -314,7 +354,7 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
314
354
  'vc-card-header-main-title text-[#18224E] pt-[3px] leading-[80%] text-[32px]';
315
355
 
316
356
  return (
317
- <Flipper className="w-full" flipKey={isFront}>
357
+ <Flipper className="vc-display-card-ribbon-safe-area w-full" flipKey={isFront}>
318
358
  <Flipped flipId="card">
319
359
  <section
320
360
  className="vc-display-card font-poppins flex flex-col items-center border-solid border-[5px] border-white rounded-[30px] z-10 min-h-[800px] max-w-[400px] relative bg-white shadow-3xl"
@@ -325,14 +365,14 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
325
365
  <RibbonEnd
326
366
  side="left"
327
367
  className="absolute left-[-30px] top-[50px] z-0"
328
- height={'75'}
368
+ height={ribbonEndHeight}
329
369
  />
330
370
  </Flipped>
331
371
  <Flipped inverseFlipId="card">
332
372
  <RibbonEnd
333
373
  side="right"
334
374
  className="absolute right-[-30px] top-[50px] z-0"
335
- height={'75'}
375
+ height={ribbonEndHeight}
336
376
  />
337
377
  </Flipped>
338
378
 
@@ -351,7 +391,7 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
351
391
  text={_title ?? ''}
352
392
  maxFontSize={32}
353
393
  minFontSize={20}
354
- width={((headerWidth ?? 290) - 40).toString()}
394
+ width="100%"
355
395
  className={headerFitTextClassName}
356
396
  />
357
397
  </h1>
@@ -390,7 +430,7 @@ export const VCDisplayCard2: React.FC<VCDisplayCard2Props> = ({
390
430
  text={_title ?? ''}
391
431
  maxFontSize={32}
392
432
  minFontSize={20}
393
- width={((headerWidth ?? 290) - 40).toString()}
433
+ width="100%"
394
434
  className={headerFitTextClassName}
395
435
  />
396
436
  </h1>
@@ -20,6 +20,7 @@ const MESSAGE_KEYS: Record<string, string> = {
20
20
  Invalid: 'invalid',
21
21
  'Not Revoked': 'notRevoked',
22
22
  Revoked: 'revoked',
23
+ Suspended: 'suspended',
23
24
  'Does Not Expire': 'doesNotExpire',
24
25
  Expired: 'expired',
25
26
  Active: 'active',
@@ -0,0 +1,140 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import React from 'react';
4
+ import { render } from '@testing-library/react';
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
6
+ import type { MockInstance } from 'vitest';
7
+
8
+ import FitText from '../FitText';
9
+
10
+ let resizeObserverCallback: ResizeObserverCallback;
11
+ let clientWidth = 212;
12
+ let scrollWidth = 400;
13
+
14
+ const observe = vi.fn();
15
+ const disconnect = vi.fn();
16
+
17
+ class ResizeObserverMock {
18
+ constructor(callback: ResizeObserverCallback) {
19
+ resizeObserverCallback = callback;
20
+ }
21
+
22
+ observe = observe;
23
+ disconnect = disconnect;
24
+ unobserve = vi.fn();
25
+ }
26
+
27
+ describe('FitText', () => {
28
+ let clientWidthSpy: MockInstance<[], number>;
29
+ let scrollWidthSpy: MockInstance<[], number>;
30
+
31
+ beforeEach(() => {
32
+ clientWidth = 212;
33
+ scrollWidth = 400;
34
+ observe.mockClear();
35
+ disconnect.mockClear();
36
+
37
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
38
+ clientWidthSpy = vi
39
+ .spyOn(HTMLElement.prototype, 'clientWidth', 'get')
40
+ .mockImplementation(() => clientWidth);
41
+ scrollWidthSpy = vi
42
+ .spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
43
+ .mockImplementation(function () {
44
+ const fontSize = Number.parseFloat(this.style.fontSize) || 100;
45
+ return (scrollWidth * fontSize) / 100;
46
+ });
47
+ });
48
+
49
+ afterEach(() => {
50
+ clientWidthSpy.mockRestore();
51
+ scrollWidthSpy.mockRestore();
52
+ vi.unstubAllGlobals();
53
+ });
54
+
55
+ test('refits only when the observed width changes', () => {
56
+ const { container, getByText } = render(
57
+ <FitText text="A responsive ribbon title" width="100%" maxFontSize={100} />
58
+ );
59
+
60
+ const observedElement = container.firstElementChild as Element;
61
+
62
+ resizeObserverCallback(
63
+ [
64
+ {
65
+ contentRect: { width: 200, height: 40 },
66
+ target: observedElement,
67
+ } as ResizeObserverEntry,
68
+ ],
69
+ {} as ResizeObserver
70
+ );
71
+ const readsAfterFirstResize = scrollWidthSpy.mock.calls.length;
72
+
73
+ resizeObserverCallback(
74
+ [
75
+ {
76
+ contentRect: { width: 200, height: 80 },
77
+ target: observedElement,
78
+ } as ResizeObserverEntry,
79
+ ],
80
+ {} as ResizeObserver
81
+ );
82
+
83
+ expect(scrollWidthSpy).toHaveBeenCalledTimes(readsAfterFirstResize);
84
+
85
+ clientWidth = 172;
86
+ resizeObserverCallback(
87
+ [
88
+ {
89
+ contentRect: { width: 160, height: 80 },
90
+ target: observedElement,
91
+ } as ResizeObserverEntry,
92
+ ],
93
+ {} as ResizeObserver
94
+ );
95
+
96
+ expect(scrollWidthSpy.mock.calls.length).toBeGreaterThan(readsAfterFirstResize);
97
+ expect(getByText('A responsive ribbon title').style.fontSize).toBe('40px');
98
+ });
99
+
100
+ test('wraps only after reaching the minimum font size', () => {
101
+ clientWidth = 112;
102
+ scrollWidth = 1_200;
103
+
104
+ const { getByText } = render(
105
+ <FitText
106
+ text="An exceptionally long ribbon title"
107
+ width="100%"
108
+ minFontSize={10}
109
+ maxFontSize={100}
110
+ />
111
+ );
112
+ const textElement = getByText('An exceptionally long ribbon title');
113
+
114
+ expect(textElement.style.fontSize).toBe('10px');
115
+ expect(textElement.style.whiteSpace).toBe('normal');
116
+ });
117
+
118
+ test('keeps the maximum font size when the text fits', () => {
119
+ clientWidth = 350;
120
+ scrollWidth = 200;
121
+
122
+ const { getByText } = render(
123
+ <FitText text="Short title" width="100%" minFontSize={10} maxFontSize={32} />
124
+ );
125
+
126
+ expect(getByText('Short title').style.fontSize).toBe('32px');
127
+ });
128
+
129
+ test('still fits when ResizeObserver is unavailable', () => {
130
+ vi.stubGlobal('ResizeObserver', undefined);
131
+ clientWidth = 350;
132
+ scrollWidth = 200;
133
+
134
+ const { getByText } = render(
135
+ <FitText text="Short title" width="100%" minFontSize={10} maxFontSize={32} />
136
+ );
137
+
138
+ expect(getByText('Short title').style.fontSize).toBe('32px');
139
+ });
140
+ });
@@ -0,0 +1,31 @@
1
+ import type { VC } from '@learncard/types';
2
+
3
+ import { getVCDisplayCardVariant } from '../VCDisplayCard2';
4
+ import { LCCategoryEnum } from '../../../types';
5
+
6
+ const credentialWithDisplayType = (displayType: string): VC =>
7
+ ({ display: { displayType } } as unknown as VC);
8
+
9
+ describe('getVCDisplayCardVariant', () => {
10
+ it('uses the ribbon layout for a generic credential', () => {
11
+ expect(getVCDisplayCardVariant({} as VC)).toBe('ribbon');
12
+ });
13
+
14
+ it('detects each specialized display type', () => {
15
+ expect(getVCDisplayCardVariant(credentialWithDisplayType('award'))).toBe('award');
16
+ expect(getVCDisplayCardVariant(credentialWithDisplayType('certificate'))).toBe(
17
+ 'certificate'
18
+ );
19
+ expect(getVCDisplayCardVariant(credentialWithDisplayType('id'))).toBe('id');
20
+ expect(getVCDisplayCardVariant(credentialWithDisplayType('ID'))).toBe('id');
21
+ });
22
+
23
+ it('uses category metadata when display metadata is absent', () => {
24
+ expect(getVCDisplayCardVariant({} as VC, LCCategoryEnum.meritBadge)).toBe('award');
25
+ expect(getVCDisplayCardVariant({} as VC, LCCategoryEnum.id)).toBe('id');
26
+ });
27
+
28
+ it('uses the formatted display type as a final fallback', () => {
29
+ expect(getVCDisplayCardVariant({} as VC, undefined, 'Certificate')).toBe('certificate');
30
+ });
31
+ });