@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.
- package/dist/base.css +1 -1
- package/dist/base.js +1 -1
- package/dist/cjs/{BoostGenericCard-99a3ab28.js → BoostGenericCard-71899305.js} +66 -7
- package/dist/cjs/BoostGenericCard-71899305.js.map +1 -0
- package/dist/cjs/{CertificateDisplayCard-b673e66d.js → CertificateDisplayCard-f10b46b1.js} +5 -5
- package/dist/cjs/{CertificateDisplayCard-b673e66d.js.map → CertificateDisplayCard-f10b46b1.js.map} +1 -1
- package/dist/cjs/{Lightbox-d72e19ba.js → Lightbox-e2996736.js} +100 -2
- package/dist/cjs/Lightbox-e2996736.js.map +1 -0
- package/dist/cjs/{MeritBadgeDisplayCard-15b85a79.js → MeritBadgeDisplayCard-d50cfb5e.js} +5 -5
- package/dist/cjs/{MeritBadgeDisplayCard-15b85a79.js.map → MeritBadgeDisplayCard-d50cfb5e.js.map} +1 -1
- package/dist/cjs/{MeritBadgeSkillsCount-f62ad753.js → MeritBadgeSkillsCount-e46503f5.js} +2 -2
- package/dist/cjs/{MeritBadgeSkillsCount-f62ad753.js.map → MeritBadgeSkillsCount-e46503f5.js.map} +1 -1
- package/dist/cjs/{SchoolIDCard-10796199.js → SchoolIDCard-2d3cbb2c.js} +3 -1184
- package/dist/cjs/SchoolIDCard-2d3cbb2c.js.map +1 -0
- package/dist/cjs/{VCCard-4f02e2f4.js → VCCard-42479c97.js} +3 -3
- package/dist/cjs/{VCCard-4f02e2f4.js.map → VCCard-42479c97.js.map} +1 -1
- package/dist/cjs/{VCDisplayBackFace-aab4d52c.js → VCDisplayBackFace-d1cf9f73.js} +3 -3
- package/dist/cjs/{VCDisplayBackFace-aab4d52c.js.map → VCDisplayBackFace-d1cf9f73.js.map} +1 -1
- package/dist/cjs/{VCDisplayCard-95b7f7dc.js → VCDisplayCard-cf46c224.js} +3 -3
- package/dist/cjs/{VCDisplayCard-95b7f7dc.js.map → VCDisplayCard-cf46c224.js.map} +1 -1
- package/dist/cjs/{VCDisplayCard2-8517f419.js → VCDisplayCard2-4a201a8a.js} +95 -69
- package/dist/cjs/VCDisplayCard2-4a201a8a.js.map +1 -0
- package/dist/cjs/{VCVerificationPill-b5824def.js → VCVerificationPill-6c9dcf80.js} +2 -2
- package/dist/cjs/{VCVerificationPill-b5824def.js.map → VCVerificationPill-6c9dcf80.js.map} +1 -1
- package/dist/cjs/{VerifierStateBadgeAndText-c15064b2.js → VerifierStateBadgeAndText-934239ab.js} +134 -6
- package/dist/cjs/VerifierStateBadgeAndText-934239ab.js.map +1 -0
- package/dist/cjs/{credential.helpers-98fc74d8.js → credential.helpers-17a10601.js} +2 -2
- package/dist/cjs/{credential.helpers-98fc74d8.js.map → credential.helpers-17a10601.js.map} +1 -1
- package/dist/cjs/{i18n-cc7e34ec.js → i18n-8fd90b2f.js} +5 -2
- package/dist/cjs/i18n-8fd90b2f.js.map +1 -0
- package/dist/cjs/index-47efbf5b.js +1186 -0
- package/dist/cjs/index-47efbf5b.js.map +1 -0
- package/dist/cjs/index-5488badc.js.map +1 -1
- package/dist/cjs/index.js +28 -19
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/index14.js +18 -15
- package/dist/cjs/index14.js.map +1 -1
- package/dist/cjs/index20.js +2 -1
- package/dist/cjs/index20.js.map +1 -1
- package/dist/cjs/index22.js +7 -7
- package/dist/cjs/index31.js +2 -1
- package/dist/cjs/index31.js.map +1 -1
- package/dist/cjs/index39.js +13 -12
- package/dist/cjs/index39.js.map +1 -1
- package/dist/cjs/index4.js +4 -3
- package/dist/cjs/index4.js.map +1 -1
- package/dist/cjs/index40.js +4 -4
- package/dist/cjs/index41.js +5 -5
- package/dist/cjs/index42.js +10 -8
- package/dist/cjs/index42.js.map +1 -1
- package/dist/cjs/index43.js +1 -1
- package/dist/cjs/index46.js +2 -2
- package/dist/cjs/index7.js +5 -5
- package/dist/cjs/{types.esm-f1680b31.js → types.esm-73624b33.js} +105 -4
- package/dist/cjs/types.esm-73624b33.js.map +1 -0
- package/dist/esm/{AchievementCard-c600788a.js → AchievementCard-0f183e9d.js} +2 -2
- package/dist/esm/{AchievementCard-c600788a.js.map → AchievementCard-0f183e9d.js.map} +1 -1
- package/dist/esm/{BoostGenericCard-7dfcc887.js → BoostGenericCard-3b3ff784.js} +67 -9
- package/dist/esm/BoostGenericCard-3b3ff784.js.map +1 -0
- package/dist/esm/{BoostSmallCard-a73b6800.js → BoostSmallCard-3dedb5ae.js} +2 -2
- package/dist/esm/{BoostSmallCard-a73b6800.js.map → BoostSmallCard-3dedb5ae.js.map} +1 -1
- package/dist/esm/{CertificateDisplayCard-4e9254e0.js → CertificateDisplayCard-f26ef278.js} +5 -5
- package/dist/esm/{CertificateDisplayCard-4e9254e0.js.map → CertificateDisplayCard-f26ef278.js.map} +1 -1
- package/dist/esm/{GenericCard-a469f2f0.js → GenericCard-b40c0617.js} +2 -2
- package/dist/esm/{GenericCard-a469f2f0.js.map → GenericCard-b40c0617.js.map} +1 -1
- package/dist/esm/{JobListCard-e8b4ab33.js → JobListCard-dfb94830.js} +2 -2
- package/dist/esm/{JobListCard-e8b4ab33.js.map → JobListCard-dfb94830.js.map} +1 -1
- package/dist/esm/{LearnPill-e0720485.js → LearnPill-ce240a4a.js} +2 -2
- package/dist/esm/{LearnPill-e0720485.js.map → LearnPill-ce240a4a.js.map} +1 -1
- package/dist/esm/{Lightbox-f0338a0c.js → Lightbox-11f4888d.js} +95 -3
- package/dist/esm/Lightbox-11f4888d.js.map +1 -0
- package/dist/esm/{MeritBadgeDisplayCard-2029a552.js → MeritBadgeDisplayCard-0d0b28c7.js} +5 -5
- package/dist/esm/{MeritBadgeDisplayCard-2029a552.js.map → MeritBadgeDisplayCard-0d0b28c7.js.map} +1 -1
- package/dist/esm/{MeritBadgeSkillsCount-c5271d7f.js → MeritBadgeSkillsCount-09e211fc.js} +2 -2
- package/dist/esm/{MeritBadgeSkillsCount-c5271d7f.js.map → MeritBadgeSkillsCount-09e211fc.js.map} +1 -1
- package/dist/esm/{SchoolIDCard-f82e40bc.js → SchoolIDCard-fbd1cf6c.js} +2 -1183
- package/dist/esm/SchoolIDCard-fbd1cf6c.js.map +1 -0
- package/dist/esm/{VCCard-c0f6dff3.js → VCCard-7e5e00d7.js} +3 -3
- package/dist/esm/{VCCard-c0f6dff3.js.map → VCCard-7e5e00d7.js.map} +1 -1
- package/dist/esm/{VCDisplayBackFace-bb60a881.js → VCDisplayBackFace-ae74476d.js} +3 -3
- package/dist/esm/{VCDisplayBackFace-bb60a881.js.map → VCDisplayBackFace-ae74476d.js.map} +1 -1
- package/dist/esm/{VCDisplayCard-9905d9d3.js → VCDisplayCard-ac1f367a.js} +3 -3
- package/dist/esm/{VCDisplayCard-9905d9d3.js.map → VCDisplayCard-ac1f367a.js.map} +1 -1
- package/dist/esm/{VCDisplayCard2-fc5c4225.js → VCDisplayCard2-7011596e.js} +96 -71
- package/dist/esm/VCDisplayCard2-7011596e.js.map +1 -0
- package/dist/esm/{VCVerificationPill-83eb61f4.js → VCVerificationPill-04ffdf56.js} +2 -2
- package/dist/esm/{VCVerificationPill-83eb61f4.js.map → VCVerificationPill-04ffdf56.js.map} +1 -1
- package/dist/esm/{VerifierStateBadgeAndText-60b98d25.js → VerifierStateBadgeAndText-750a6c24.js} +134 -6
- package/dist/esm/VerifierStateBadgeAndText-750a6c24.js.map +1 -0
- package/dist/esm/{constants-f1ed610c.js → constants-010faf74.js} +2 -2
- package/dist/esm/{constants-f1ed610c.js.map → constants-010faf74.js.map} +1 -1
- package/dist/esm/{credential.helpers-00099c70.js → credential.helpers-4fbdd8e8.js} +2 -2
- package/dist/esm/{credential.helpers-00099c70.js.map → credential.helpers-4fbdd8e8.js.map} +1 -1
- package/dist/esm/{i18n-8454d6ea.js → i18n-08b606cb.js} +5 -2
- package/dist/esm/i18n-08b606cb.js.map +1 -0
- package/dist/esm/index-28917993.js.map +1 -1
- package/dist/esm/{index-cbe3a76a.js → index-7542e9ee.js} +2 -2
- package/dist/esm/{index-cbe3a76a.js.map → index-7542e9ee.js.map} +1 -1
- package/dist/esm/index-cbd2e9db.js +1184 -0
- package/dist/esm/index-cbd2e9db.js.map +1 -0
- package/dist/esm/index.js +25 -24
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index13.js +2 -2
- package/dist/esm/index14.js +23 -22
- package/dist/esm/index14.js.map +1 -1
- package/dist/esm/index16.js +2 -2
- package/dist/esm/index19.js +2 -2
- package/dist/esm/index2.js +2 -2
- package/dist/esm/index20.js +1 -1
- package/dist/esm/index22.js +7 -7
- package/dist/esm/index30.js +2 -2
- package/dist/esm/index31.js +2 -1
- package/dist/esm/index31.js.map +1 -1
- package/dist/esm/index39.js +13 -12
- package/dist/esm/index39.js.map +1 -1
- package/dist/esm/index4.js +5 -4
- package/dist/esm/index4.js.map +1 -1
- package/dist/esm/index40.js +4 -4
- package/dist/esm/index41.js +5 -5
- package/dist/esm/index42.js +9 -8
- package/dist/esm/index42.js.map +1 -1
- package/dist/esm/index43.js +1 -1
- package/dist/esm/index46.js +2 -2
- package/dist/esm/index5.js +2 -2
- package/dist/esm/index7.js +5 -5
- package/dist/esm/{types.esm-d6b48609.js → types.esm-5f75bd4a.js} +105 -4
- package/dist/esm/types.esm-5f75bd4a.js.map +1 -0
- package/dist/index.d.ts +104 -10
- package/dist/main.css +1 -1
- package/dist/main.js +1 -1
- package/package.json +3 -3
- package/src/assets/styles/main.css +10 -0
- package/src/components/BoostGenericCard/BoostGenericCard.tsx +60 -19
- package/src/components/BoostGenericCard/__tests__/BoostGenericCard.thumb.test.tsx +59 -0
- package/src/components/Lightbox/ExternalVideoFallback.tsx +53 -0
- package/src/components/Lightbox/Lightbox.tsx +23 -9
- package/src/components/Lightbox/__tests__/Lightbox.video.test.tsx +139 -0
- package/src/components/Lightbox/index.ts +1 -0
- package/src/components/VCDisplayCard2/FitText.tsx +75 -46
- package/src/components/VCDisplayCard2/VCDisplayCard2.tsx +59 -19
- package/src/components/VCDisplayCard2/VerificationRow.tsx +1 -0
- package/src/components/VCDisplayCard2/__tests__/FitText.test.tsx +140 -0
- package/src/components/VCDisplayCard2/__tests__/VCDisplayCard2Variant.test.ts +31 -0
- package/src/helpers/externalUrl.helpers.ts +51 -0
- package/src/helpers/lifecycle.helpers.ts +38 -0
- package/src/helpers/video.helpers.ts +41 -0
- package/src/i18n/index.test.ts +10 -0
- package/src/i18n/index.tsx +3 -0
- package/src/index.ts +8 -0
- package/src/types/index.ts +1 -0
- package/dist/cjs/BoostGenericCard-99a3ab28.js.map +0 -1
- package/dist/cjs/Lightbox-d72e19ba.js.map +0 -1
- package/dist/cjs/SchoolIDCard-10796199.js.map +0 -1
- package/dist/cjs/VCDisplayCard2-8517f419.js.map +0 -1
- package/dist/cjs/VerifierStateBadgeAndText-c15064b2.js.map +0 -1
- package/dist/cjs/i18n-cc7e34ec.js.map +0 -1
- package/dist/cjs/types.esm-f1680b31.js.map +0 -1
- package/dist/esm/BoostGenericCard-7dfcc887.js.map +0 -1
- package/dist/esm/Lightbox-f0338a0c.js.map +0 -1
- package/dist/esm/SchoolIDCard-f82e40bc.js.map +0 -1
- package/dist/esm/VCDisplayCard2-fc5c4225.js.map +0 -1
- package/dist/esm/VerifierStateBadgeAndText-60b98d25.js.map +0 -1
- package/dist/esm/i18n-8454d6ea.js.map +0 -1
- 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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
|
19
|
-
|
|
26
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const textRef = useRef<HTMLSpanElement>(null);
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
45
|
+
let fittedFontSize = Math.min(
|
|
46
|
+
maxFontSize,
|
|
47
|
+
Math.max(minFontSize, Math.floor((availableWidth / requiredWidth) * maxFontSize))
|
|
48
|
+
);
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
textElement.style.fontSize = `${fittedFontSize}px`;
|
|
39
51
|
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
textRef.current.style.whiteSpace = newFontSize === minFontSize ? 'normal' : 'nowrap';
|
|
50
|
-
}
|
|
51
|
-
};
|
|
67
|
+
fitText();
|
|
52
68
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
};
|
|
77
|
+
if (observedWidth === undefined || observedWidth === lastObservedWidth) return;
|
|
60
78
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
208
|
-
credential
|
|
244
|
+
const displayCardVariant = getVCDisplayCardVariant(
|
|
245
|
+
credential,
|
|
246
|
+
categoryType,
|
|
247
|
+
formattedDisplayType
|
|
248
|
+
);
|
|
209
249
|
|
|
210
|
-
if (
|
|
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 (
|
|
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 (
|
|
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={
|
|
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={
|
|
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=
|
|
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=
|
|
433
|
+
width="100%"
|
|
394
434
|
className={headerFitTextClassName}
|
|
395
435
|
/>
|
|
396
436
|
</h1>
|
|
@@ -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
|
+
});
|