@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.
- package/dist/base.css +1 -1
- package/dist/base.js +1 -1
- package/dist/cjs/{BoostGenericCard-feaf99bd.js → BoostGenericCard-71899305.js} +22 -3
- package/dist/cjs/BoostGenericCard-71899305.js.map +1 -0
- package/dist/cjs/{CertificateDisplayCard-de7d7318.js → CertificateDisplayCard-f10b46b1.js} +5 -5
- package/dist/cjs/{CertificateDisplayCard-de7d7318.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-6edd0a69.js → MeritBadgeDisplayCard-d50cfb5e.js} +5 -5
- package/dist/cjs/{MeritBadgeDisplayCard-6edd0a69.js.map → MeritBadgeDisplayCard-d50cfb5e.js.map} +1 -1
- package/dist/cjs/{MeritBadgeSkillsCount-7ac48ce3.js → MeritBadgeSkillsCount-e46503f5.js} +2 -2
- package/dist/cjs/{MeritBadgeSkillsCount-7ac48ce3.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-0b85bc7d.js → VCCard-42479c97.js} +3 -3
- package/dist/cjs/{VCCard-0b85bc7d.js.map → VCCard-42479c97.js.map} +1 -1
- package/dist/cjs/{VCDisplayBackFace-45a8d94f.js → VCDisplayBackFace-d1cf9f73.js} +3 -3
- package/dist/cjs/{VCDisplayBackFace-45a8d94f.js.map → VCDisplayBackFace-d1cf9f73.js.map} +1 -1
- package/dist/cjs/{VCDisplayCard-9e43c7db.js → VCDisplayCard-cf46c224.js} +3 -3
- package/dist/cjs/{VCDisplayCard-9e43c7db.js.map → VCDisplayCard-cf46c224.js.map} +1 -1
- package/dist/cjs/{VCDisplayCard2-81d04ca4.js → VCDisplayCard2-4a201a8a.js} +93 -68
- package/dist/cjs/VCDisplayCard2-4a201a8a.js.map +1 -0
- package/dist/cjs/{VCVerificationPill-9416f420.js → VCVerificationPill-6c9dcf80.js} +2 -2
- package/dist/cjs/{VCVerificationPill-9416f420.js.map → VCVerificationPill-6c9dcf80.js.map} +1 -1
- package/dist/cjs/{VerifierStateBadgeAndText-54065ad8.js → VerifierStateBadgeAndText-934239ab.js} +13 -4
- package/dist/{esm/VerifierStateBadgeAndText-2859597e.js.map → cjs/VerifierStateBadgeAndText-934239ab.js.map} +1 -1
- package/dist/cjs/{credential.helpers-cf5b7e3c.js → credential.helpers-17a10601.js} +2 -2
- package/dist/cjs/{credential.helpers-cf5b7e3c.js.map → credential.helpers-17a10601.js.map} +1 -1
- package/dist/cjs/index-47efbf5b.js +1186 -0
- package/dist/cjs/index-47efbf5b.js.map +1 -0
- package/dist/cjs/index.js +24 -16
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/index14.js +17 -14
- 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 +6 -6
- package/dist/cjs/index31.js +2 -1
- package/dist/cjs/index31.js.map +1 -1
- package/dist/cjs/index39.js +12 -11
- package/dist/cjs/index39.js.map +1 -1
- package/dist/cjs/index4.js +3 -3
- package/dist/cjs/index40.js +4 -4
- package/dist/cjs/index41.js +5 -5
- package/dist/cjs/index42.js +9 -7
- 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-e775534b.js → types.esm-73624b33.js} +12 -3
- package/dist/cjs/types.esm-73624b33.js.map +1 -0
- package/dist/esm/{BoostGenericCard-f0c9952c.js → BoostGenericCard-3b3ff784.js} +22 -3
- package/dist/esm/BoostGenericCard-3b3ff784.js.map +1 -0
- package/dist/esm/{CertificateDisplayCard-34e0b6a8.js → CertificateDisplayCard-f26ef278.js} +5 -5
- package/dist/esm/{CertificateDisplayCard-34e0b6a8.js.map → CertificateDisplayCard-f26ef278.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-c6f65ac6.js → MeritBadgeDisplayCard-0d0b28c7.js} +5 -5
- package/dist/esm/{MeritBadgeDisplayCard-c6f65ac6.js.map → MeritBadgeDisplayCard-0d0b28c7.js.map} +1 -1
- package/dist/esm/{MeritBadgeSkillsCount-c51c5111.js → MeritBadgeSkillsCount-09e211fc.js} +2 -2
- package/dist/esm/{MeritBadgeSkillsCount-c51c5111.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-6c85ef2e.js → VCCard-7e5e00d7.js} +3 -3
- package/dist/esm/{VCCard-6c85ef2e.js.map → VCCard-7e5e00d7.js.map} +1 -1
- package/dist/esm/{VCDisplayBackFace-211f4bfb.js → VCDisplayBackFace-ae74476d.js} +3 -3
- package/dist/esm/{VCDisplayBackFace-211f4bfb.js.map → VCDisplayBackFace-ae74476d.js.map} +1 -1
- package/dist/esm/{VCDisplayCard-04660178.js → VCDisplayCard-ac1f367a.js} +3 -3
- package/dist/esm/{VCDisplayCard-04660178.js.map → VCDisplayCard-ac1f367a.js.map} +1 -1
- package/dist/esm/{VCDisplayCard2-d4594742.js → VCDisplayCard2-7011596e.js} +94 -70
- package/dist/esm/VCDisplayCard2-7011596e.js.map +1 -0
- package/dist/esm/{VCVerificationPill-eafbb82e.js → VCVerificationPill-04ffdf56.js} +2 -2
- package/dist/esm/{VCVerificationPill-eafbb82e.js.map → VCVerificationPill-04ffdf56.js.map} +1 -1
- package/dist/esm/{VerifierStateBadgeAndText-2859597e.js → VerifierStateBadgeAndText-750a6c24.js} +13 -4
- package/dist/{cjs/VerifierStateBadgeAndText-54065ad8.js.map → esm/VerifierStateBadgeAndText-750a6c24.js.map} +1 -1
- package/dist/esm/{credential.helpers-845e8c09.js → credential.helpers-4fbdd8e8.js} +2 -2
- package/dist/esm/{credential.helpers-845e8c09.js.map → credential.helpers-4fbdd8e8.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 +16 -15
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index14.js +15 -14
- package/dist/esm/index14.js.map +1 -1
- package/dist/esm/index20.js +1 -1
- package/dist/esm/index22.js +6 -6
- package/dist/esm/index31.js +2 -1
- package/dist/esm/index31.js.map +1 -1
- package/dist/esm/index39.js +12 -11
- package/dist/esm/index39.js.map +1 -1
- package/dist/esm/index4.js +3 -3
- package/dist/esm/index40.js +4 -4
- package/dist/esm/index41.js +5 -5
- package/dist/esm/index42.js +8 -7
- 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/index7.js +5 -5
- package/dist/esm/{types.esm-5b5092be.js → types.esm-5f75bd4a.js} +12 -3
- package/dist/esm/types.esm-5f75bd4a.js.map +1 -0
- package/dist/index.d.ts +81 -9
- 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 +9 -2
- 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/__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/video.helpers.ts +41 -0
- package/src/index.ts +5 -0
- package/dist/cjs/BoostGenericCard-feaf99bd.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-81d04ca4.js.map +0 -1
- package/dist/cjs/types.esm-e775534b.js.map +0 -1
- package/dist/esm/BoostGenericCard-f0c9952c.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-d4594742.js.map +0 -1
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
});
|
|
@@ -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>
|