Skip to content

Commit 37bd14e

Browse files
feature/add-openDocumentPictureInPicture-function (#77)
1 parent 5134cbb commit 37bd14e

File tree

8 files changed

+864
-133
lines changed

8 files changed

+864
-133
lines changed

README.md

Lines changed: 493 additions & 131 deletions
Large diffs are not rendered by default.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import {
5+
isDocumentPictureInPictureSupported,
6+
requiresDocumentPictureInPictureAPI,
7+
openDocumentPictureInPicture,
8+
type OpenDocumentPictureInPictureOptions,
9+
} from '@/browser-api/picture-in-picture'
10+
import { ErrorCode } from '@/errors'
11+
12+
import { cloneStyleSheets as _cloneStyleSheets, type Styles } from '@/dom'
13+
14+
15+
jest.mock( '@/dom', () => ( {
16+
cloneStyleSheets: jest.fn( async () => {
17+
return [
18+
document.createElement( 'style' )
19+
]
20+
} )
21+
} ) )
22+
23+
24+
const cloneStyleSheets = _cloneStyleSheets as (
25+
jest.Mock<ReturnType<typeof _cloneStyleSheets>, Parameters<typeof _cloneStyleSheets>>
26+
)
27+
28+
29+
interface RequestWindowOptions extends Omit<OpenDocumentPictureInPictureOptions, 'sizes'| 'styles' | 'onQuit'>
30+
{
31+
width: number
32+
height: number
33+
}
34+
35+
36+
describe( 'Document Picture-in-Picture', () => {
37+
38+
let mockDocumentPiPWindow: {
39+
width: number
40+
height: number
41+
disallowReturnToOpener?: boolean
42+
preferInitialWindowPlacement?: boolean
43+
addEventListener: jest.Mock<void, [ event: string, listener: ( event: Event ) => void ]>
44+
document: {
45+
head: HTMLHeadElement
46+
}
47+
}
48+
49+
let mockRequestWindow: jest.Mock<typeof mockDocumentPiPWindow, [ options: RequestWindowOptions ]>
50+
51+
let mockDocumentPictureInPicture: jest.Mock<{
52+
requestWindow?: typeof mockRequestWindow
53+
}>
54+
55+
beforeEach( () => {
56+
57+
mockDocumentPiPWindow = {
58+
width: 0,
59+
height: 0,
60+
disallowReturnToOpener: undefined,
61+
preferInitialWindowPlacement: undefined,
62+
addEventListener: jest.fn( ( event, listener ) => {
63+
listener( new Event( event ) )
64+
} ),
65+
document: {
66+
head: document.createElement( 'head' )
67+
}
68+
}
69+
70+
mockRequestWindow = jest.fn( options => {
71+
Object.entries( options ).map( ( [ key, value ] ) => {
72+
// @ts-expect-error couldn't infer types
73+
mockDocumentPiPWindow[ key as keyof typeof mockDocumentPiPWindow ] = value
74+
} )
75+
return mockDocumentPiPWindow
76+
} )
77+
78+
mockDocumentPictureInPicture = jest.fn( () => ( {
79+
requestWindow: mockRequestWindow
80+
} ) )
81+
82+
Object.defineProperty( window, 'documentPictureInPicture', {
83+
configurable : true,
84+
get : mockDocumentPictureInPicture,
85+
} )
86+
87+
} )
88+
89+
90+
afterEach( () => {
91+
jest.clearAllMocks()
92+
Object.defineProperty( window, 'documentPictureInPicture', {
93+
configurable : true,
94+
get : undefined,
95+
} )
96+
} )
97+
98+
99+
describe( 'isDocumentPictureInPictureSupported', () => {
100+
101+
it( 'returns true if Document Picture-in-Picture API is available', () => {
102+
103+
expect( isDocumentPictureInPictureSupported() ).toBe( true )
104+
105+
} )
106+
107+
108+
it( 'returns false if Document Picture-in-Picture API is not available', () => {
109+
110+
mockDocumentPictureInPicture.mockReturnValue( {} )
111+
expect( isDocumentPictureInPictureSupported() ).toBe( false )
112+
113+
} )
114+
115+
} )
116+
117+
118+
describe( 'requiresDocumentPictureInPictureAPI', () => {
119+
120+
it( 'throws an Exception if Document Picture-in-Picture is not supported', () => {
121+
122+
mockDocumentPictureInPicture.mockReturnValue( {} )
123+
124+
expect( requiresDocumentPictureInPictureAPI )
125+
.toThrow( expect.objectContaining( { code: ErrorCode.DOCUMENT_PIP_NOT_SUPPORTED } ) )
126+
127+
} )
128+
129+
130+
it( 'doesn\'t throw any Exception if Document Picture-in-Picture is supported', () => {
131+
132+
expect( requiresDocumentPictureInPictureAPI ).not.toThrow()
133+
134+
} )
135+
136+
} )
137+
138+
139+
describe( 'openDocumentPictureInPicture', () => {
140+
141+
it( 'returns the new browsing context inside the Document Picture-in-Picture window with default options', async () => {
142+
143+
const { window: pipWindow } = await openDocumentPictureInPicture()
144+
145+
expect( pipWindow ).toBeDefined()
146+
expect( pipWindow ).toBe( mockDocumentPiPWindow ) // this pass only because we mocked the return value of `requestWindow`
147+
expect( mockRequestWindow )
148+
.toHaveBeenCalledWith( {
149+
width: 250,
150+
height: 250,
151+
disallowReturnToOpener: undefined,
152+
preferInitialWindowPlacement: undefined,
153+
} )
154+
155+
} )
156+
157+
158+
it( 'opens the Document Picture-in-Picture window with the given options', async () => {
159+
160+
await openDocumentPictureInPicture( {
161+
sizes: [ 200, 300 ],
162+
disallowReturnToOpener: true,
163+
preferInitialWindowPlacement: true,
164+
} )
165+
166+
expect( mockRequestWindow )
167+
.toHaveBeenCalledWith( {
168+
width: 200,
169+
height: 300,
170+
disallowReturnToOpener: true,
171+
preferInitialWindowPlacement: true,
172+
} )
173+
174+
} )
175+
176+
177+
it( 'clones the current document stylesheets', async () => {
178+
179+
await openDocumentPictureInPicture()
180+
181+
expect( cloneStyleSheets )
182+
.toHaveBeenCalledWith( document.styleSheets )
183+
184+
} )
185+
186+
187+
it( 'clones the given stylesheets', async () => {
188+
189+
const styles: Styles = { url: '/path-to-custom-style.css', fetch: true }
190+
191+
await openDocumentPictureInPicture( { styles } )
192+
193+
expect( cloneStyleSheets )
194+
.toHaveBeenNthCalledWith( 2, styles )
195+
196+
} )
197+
198+
199+
it( 'calls given onQuit callback on Document Picture-in-Picture pagehide event', async () => {
200+
201+
const onQuit = jest.fn()
202+
203+
await openDocumentPictureInPicture( { onQuit } )
204+
205+
expect( onQuit ).toHaveBeenCalled()
206+
207+
} )
208+
209+
} )
210+
211+
} )

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
},
123123
"dependencies": {
124124
"@alessiofrittoli/date-utils": "^4.1.0",
125+
"@alessiofrittoli/exception": "^3.5.0",
125126
"@alessiofrittoli/fetcher": "^1.1.0",
126127
"@alessiofrittoli/math-utils": "^2.0.0",
127128
"@alessiofrittoli/type-utils": "^1.9.0",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { Exception } from '@alessiofrittoli/exception'
2+
3+
import { cloneStyleSheets, type Styles } from '@/dom'
4+
import { getDimensions, type InputDimensions } from '@/utils'
5+
import { ErrorCode } from '@/errors'
6+
7+
8+
/**
9+
* Checks if the Document Picture-in-Picture API is supported by the current browser.
10+
*
11+
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Document_Picture-in-Picture_API)
12+
*
13+
* @returns `true` if Document Picture-in-Picture is supported, `false` otherwise.
14+
*/
15+
export const isDocumentPictureInPictureSupported = () => (
16+
'documentPictureInPicture' in window &&
17+
// @ts-expect-error ⚠️ Limited availability API.
18+
typeof window.documentPictureInPicture.requestWindow === 'function'
19+
)
20+
21+
22+
/**
23+
* Validates that the Document Picture-in-Picture API is supported in the current browser.
24+
*
25+
* @throws {Exception} Throws a new Exception if the Document Picture-in-Picture API is not supported.
26+
*/
27+
export const requiresDocumentPictureInPictureAPI = () => {
28+
if ( ! isDocumentPictureInPictureSupported() ) {
29+
throw new Exception(
30+
'The Document Picture-in-Picture API is not supported in the current browser.', {
31+
code: ErrorCode.DOCUMENT_PIP_NOT_SUPPORTED,
32+
}
33+
)
34+
}
35+
}
36+
37+
38+
/**
39+
* Defines configuration options for opening a Document Picture-in-Picture window.
40+
*
41+
*/
42+
export interface OpenDocumentPictureInPictureOptions
43+
{
44+
/**
45+
* A tuple defining non-negative numbers representing the width and the height to set for the Picture-in-Picture window's viewport, in pixels.
46+
*
47+
* @default [ 250, 250 ]
48+
*/
49+
sizes?: InputDimensions
50+
/**
51+
* Hints to the browser that it should not display a UI control that enables the user to return to the originating tab and close the Picture-in-Picture window.
52+
*
53+
* @default false
54+
*/
55+
disallowReturnToOpener?: boolean
56+
/**
57+
* Defines whether the Picture-in-Picture window will always appear back at the position and size it initially opened at,
58+
* when it is closed and then reopened.
59+
*
60+
* By contrast, if `preferInitialWindowPlacement` is `false` the Picture-in-Picture window's size and position will be remembered
61+
* when closed and reopened — it will reopen at its previous position and size, for example as set by the user.
62+
*
63+
* @default false
64+
*/
65+
preferInitialWindowPlacement?: boolean
66+
/**
67+
* Custom styles to load inside the Picture-in-Picture window.
68+
*
69+
* ⚠️ To keep consistent styling with your web-app, document styles are automatically cloned.
70+
*/
71+
styles?: Styles
72+
/**
73+
* A callback to execute when Picture-in-Picture window is closed.
74+
*
75+
*/
76+
onQuit?: () => void
77+
}
78+
79+
80+
/**
81+
* Defines the returned result of opening a Document Picture-in-Picture window.
82+
*
83+
*/
84+
export interface OpenDocumentPictureInPicture
85+
{
86+
/**
87+
* The browsing context inside the Document Picture-in-Picture window.
88+
*
89+
*/
90+
window: Window
91+
}
92+
93+
94+
/**
95+
* Opens a Document Picture-in-Picture window.
96+
*
97+
* @param options Configuration options for opening a new Document Picture-in-Picture window.
98+
* See {@link OpenDocumentPictureInPictureOptions} for more info.
99+
*
100+
* @returns A new Promise that resolves to the Document Picture-in-Picture result containing the `window` of the new browsing context.
101+
* See {@link OpenDocumentPictureInPicture} for more info.
102+
*
103+
* @throws {Exception} Throws a new Exception if the Document Picture-in-Picture API is not supported.
104+
*
105+
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Document_Picture-in-Picture_API)
106+
*/
107+
export const openDocumentPictureInPicture = async (
108+
options: OpenDocumentPictureInPictureOptions = {},
109+
): Promise<OpenDocumentPictureInPicture> => {
110+
111+
requiresDocumentPictureInPictureAPI()
112+
113+
const {
114+
sizes, styles: customStyles, onQuit, ...rest
115+
} = options
116+
117+
const [
118+
width = 250,
119+
height = width,
120+
] = getDimensions( sizes )
121+
122+
const styles = await (
123+
cloneStyleSheets( document.styleSheets )
124+
.then( async result => (
125+
customStyles
126+
? result.concat( await cloneStyleSheets( customStyles ) )
127+
: result
128+
) )
129+
)
130+
131+
// @ts-expect-error types not implemented yet.
132+
const pipWindow = await window.documentPictureInPicture.requestWindow( {
133+
width, height, ...rest
134+
} ) as Window
135+
136+
styles.map( style => pipWindow.document.head.appendChild( style ) )
137+
138+
if ( onQuit ) {
139+
pipWindow.addEventListener( 'pagehide', onQuit, { once: true } )
140+
}
141+
142+
return { window: pipWindow }
143+
144+
}

src/dom.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ export type CloneStyleSheetsReturn = ( HTMLStyleElement | HTMLLinkElement )[]
9191
* styles.forEach( style => shadowRoot.appendChild( style ) )
9292
* ```
9393
*/
94-
export const cloneStyleSheetList = ( styleSheets: StyleSheetList | CSSStyleSheet[] ) => (
95-
[ ...styleSheets ].map( ( { cssRules } ) => {
94+
export const cloneStyleSheetList = ( styles: StyleSheetList | CSSStyleSheet[] ): HTMLStyleElement[] => (
95+
[ ...styles ].map( ( { cssRules } ) => {
9696
try {
9797

9898
if ( cssRules.length <= 0 ) return

src/errors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ErrorCode as Exception } from '@alessiofrittoli/exception/code';
2+
3+
export const WebUtils = {
4+
DOCUMENT_PIP_NOT_SUPPORTED: 'ERR:DOCUMENTPIPNOTSUPPORTED',
5+
} as const
6+
7+
export const ErrorCode = { ...Exception, ...WebUtils }
8+
export type ErrorCode = ( typeof ErrorCode )[ keyof typeof ErrorCode ]

0 commit comments

Comments
 (0)