-
Notifications
You must be signed in to change notification settings - Fork 221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(popup-stack): Add support for the fullscreen API #1403
Changes from all commits
ae9a581
841fe82
6e0b093
4f2b0ae
b14ed50
3409105
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
import screenfull from 'screenfull'; | ||
|
||
/** | ||
* This type is purposely an interface so that it can be extended for a specific use-case. | ||
*/ | ||
|
@@ -106,6 +108,7 @@ function getChildPopups(item: PopupStackItem, items: PopupStackItem[]): PopupSta | |
|
||
interface Stack { | ||
items: PopupStackItem[]; | ||
container?: () => HTMLElement; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional. Falls back to |
||
zIndex: { | ||
min: number; | ||
max: number; | ||
|
@@ -183,13 +186,23 @@ const setToWindow = (path: string, value: any) => { | |
// defined on the page, we need to use that one. Never, ever, ever change this variable name on | ||
// window | ||
const stack: Stack = getFromWindow('workday.__popupStack') || { | ||
description: 'Global popup stack from @workday/canvas-kit-popup-stack', | ||
items: [] as PopupStackItem[], | ||
description: 'Global popup stack from @workday/canvas-kit/popup-stack', | ||
container: () => document.body, | ||
items: [], | ||
zIndex: {min: 30, max: 50, getValue: getValue}, | ||
_adapter: {} as Partial<typeof PopupStack>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why are we removing the typing here for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
_adapter: {}, | ||
}; | ||
setToWindow('workday.__popupStack', stack); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe too early to consider this, but is the plan to eventually deprecate the singular stack? If so, we should note this is only here for backwards compatibility as well There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's possible, but a single stack needs to be created as the base stack and that needs to be shared. It could be that the first import of It is possible to deprecate, but that would take a lot of thought and testing. I have no plans to remove it at the moment. |
||
|
||
const stacks: Stack[] = getFromWindow('workday.__popupStackOfStacks') || [stack]; | ||
|
||
(stacks as any).description = 'Global stack of popup stacks from @workday/canvas-kit/popup-stack'; | ||
setToWindow('workday.__popupStackOfStacks', stacks); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the new variable added to |
||
|
||
function getTopStack() { | ||
return stacks[stacks.length - 1]; | ||
} | ||
|
||
export const PopupStack = { | ||
/** | ||
* Create a HTMLElement as the container for the popup stack item. The returned element reference | ||
|
@@ -198,6 +211,7 @@ export const PopupStack = { | |
* should be added to this element. | ||
*/ | ||
createContainer(): HTMLElement { | ||
const stack = getTopStack(); | ||
if (stack._adapter?.createContainer) { | ||
return stack._adapter.createContainer(); | ||
} | ||
|
@@ -212,31 +226,37 @@ export const PopupStack = { | |
* method when the event triggers. | ||
*/ | ||
add(item: PopupStackItem): void { | ||
const stack = getTopStack(); | ||
if (stack._adapter?.add) { | ||
stack._adapter.add(item); | ||
return; | ||
} | ||
stack.items.push(item); | ||
document.body.appendChild(item.element); | ||
(stack.container?.() || document.body).appendChild(item.element); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All the |
||
|
||
setZIndexOfElements(PopupStack.getElements()); | ||
}, | ||
|
||
/** | ||
* Removes an item from the stack by its `HTMLElement` reference. This should be called when a | ||
* popup is "closed" or when the element is removed from the page entirely to ensure proper memory | ||
* cleanup. This will not automatically be called when the element is removed from the DOM. Will | ||
* reset z-index values of the stack | ||
* Removes an item from a stack by its `HTMLElement` reference. This should be called when a popup | ||
* is "closed" or when the element is removed from the page entirely to ensure proper memory | ||
* cleanup. A popup will be removed from the stack it is a part of. This will not automatically be | ||
* called when the element is removed from the DOM. This method will reset z-index values of the | ||
* stack. | ||
*/ | ||
remove(element: HTMLElement): void { | ||
if (stack._adapter?.remove) { | ||
stack._adapter.remove(element); | ||
return; | ||
// Find the stack the popup belongs to. | ||
const stack = find(stacks, stack => !!find(stack.items, item => item.element === element)); | ||
if (stack) { | ||
if (stack._adapter?.remove) { | ||
stack._adapter.remove(element); | ||
return; | ||
} | ||
stack.items = stack.items.filter(item => item.element !== element); | ||
(stack.container?.() || document.body).removeChild(element); | ||
|
||
setZIndexOfElements(PopupStack.getElements(stack)); | ||
} | ||
stack.items = stack.items.filter(item => item.element !== element); | ||
document.body.removeChild(element); | ||
|
||
setZIndexOfElements(PopupStack.getElements()); | ||
}, | ||
|
||
/** | ||
|
@@ -245,6 +265,7 @@ export const PopupStack = { | |
* reference that was passed to `add` | ||
*/ | ||
isTopmost(element: HTMLElement): boolean { | ||
const stack = getTopStack(); | ||
if (stack._adapter?.isTopmost) { | ||
return stack._adapter.isTopmost(element); | ||
} | ||
|
@@ -261,7 +282,8 @@ export const PopupStack = { | |
* elements in the order of lowest z-index to highest z-index. Some popup behaviors will need to | ||
* make decisions based on z-index order. | ||
*/ | ||
getElements(): HTMLElement[] { | ||
getElements(stackOverride?: Stack): HTMLElement[] { | ||
const stack = stackOverride || getTopStack(); | ||
if (stack._adapter?.getElements) { | ||
return stack._adapter.getElements(); | ||
} | ||
|
@@ -281,6 +303,7 @@ export const PopupStack = { | |
* the top of the stack. | ||
*/ | ||
bringToTop(element: HTMLElement): void { | ||
const stack = getTopStack(); | ||
if (stack._adapter?.bringToTop) { | ||
stack._adapter.bringToTop(element); | ||
return; | ||
|
@@ -320,6 +343,7 @@ export const PopupStack = { | |
* is not inside `element`). | ||
*/ | ||
contains(element: HTMLElement, eventTarget: HTMLElement): boolean { | ||
const stack = getTopStack(); | ||
if (stack._adapter?.contains) { | ||
return stack._adapter.contains(element, eventTarget); | ||
} | ||
|
@@ -344,6 +368,87 @@ export const PopupStack = { | |
} | ||
return false; | ||
}, | ||
|
||
/** | ||
* Add a new stack context for popups. This method could be called with the same element multiple | ||
* times, but should only push a new stack context once. The most common use-case for calling | ||
* `pushStackContext` is when entering fullscreen, but multiple fullscreen listeners could be | ||
* pushing the same element which is very difficult to ensure only one stack is used. To mitigate, | ||
* this method filters out multiple calls to push the same element as a new stack context. | ||
*/ | ||
pushStackContext(container: HTMLElement): void { | ||
const stack = getTopStack(); | ||
|
||
if (stack._adapter?.pushStackContext) { | ||
return stack._adapter.pushStackContext(container); | ||
} | ||
// Don't push if the container already exists. This removes duplicates | ||
if (stack.container?.() === container) { | ||
return; | ||
} | ||
|
||
const newStack: Stack = { | ||
items: [], | ||
zIndex: stack.zIndex, | ||
container: () => container, | ||
_adapter: {}, | ||
}; | ||
stacks.push(newStack); | ||
}, | ||
|
||
/** | ||
* Remove the topmost stack context. The stack context will only be removed if the top stack | ||
* context container element matches to guard against accidental remove of other stack contexts | ||
* you don't own. | ||
*/ | ||
popStackContext(container: HTMLElement): void { | ||
const stack = getTopStack(); | ||
|
||
if (stack._adapter?.popStackContext) { | ||
return stack._adapter.popStackContext(container); | ||
} | ||
|
||
if (stack.container?.() === container && stacks.length > 1) { | ||
stacks.pop(); | ||
} | ||
}, | ||
|
||
/** | ||
* Transfer the popup stack item into the current popup stack context. | ||
* | ||
* An example might be a popup | ||
* that is opened and an element goes into fullscreen. The default popup stack context is | ||
* `document.body`, but the [Fullscreen | ||
* API](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) will only render elements | ||
* that are children of the fullscreen element. If the popup isn't transferred to the current | ||
* popup stack context, the popup will remain open, but will no longer be rendered. This method | ||
* will transfer that popup to the fullscreen element so that it will render. Popups created while | ||
* in a fullscreen context that need to be transferred back when fullscreen is exited should also | ||
* call this method. While popups may still render when fullscreen is exited, popups will be | ||
* members of different popup stack contexts which will cause unspecified results (like the escape | ||
* key will choose the wrong popup as the "topmost"). | ||
*/ | ||
transferToCurrentContext(item: PopupStackItem): void { | ||
const stack = getTopStack(); | ||
|
||
if (stack._adapter?.transferToCurrentContext) { | ||
return stack._adapter.transferToCurrentContext(item); | ||
} | ||
|
||
if (find(stack.items, i => i.element === item.element)) { | ||
// The element is already in the stack, don't do anything | ||
return; | ||
} | ||
|
||
// Try to find the element in existing stacks. If it exists, we need to first remove from that | ||
// stack context | ||
const oldStack = find(stacks, stack => !!find(stack.items, i => i.element === item.element)); | ||
if (oldStack) { | ||
PopupStack.remove(item.element); | ||
} | ||
|
||
PopupStack.add(item); | ||
}, | ||
}; | ||
|
||
/** | ||
|
@@ -361,3 +466,23 @@ export function resetStack() { | |
export const createAdapter = (adapter: Partial<typeof PopupStack>) => { | ||
stack._adapter = adapter; | ||
}; | ||
|
||
// keep track of the element ourselves to avoid accidentally popping off someone else's stack | ||
// context | ||
let element: HTMLElement | null = null; | ||
|
||
// Where should this go? Each version of `PopupStack` on a page will add a listener. The | ||
// `PopupStack` should guard against multiple handlers like this simultaneously and there is no | ||
// lifecycle here. | ||
if (screenfull.isEnabled) { | ||
screenfull.on('change', () => { | ||
if (screenfull.isFullscreen) { | ||
if (screenfull.element) { | ||
element = screenfull.element as HTMLElement; | ||
PopupStack.pushStackContext(element); | ||
} | ||
} else if (element) { | ||
PopupStack.popStackContext(element); | ||
} | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. perhaps we can also instantiate this using the initialization of the stack at line aka
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Something would still need to call this method. Setting it to The way it is done here doesn't require a cross-version dependency, but does require The question about the location of this call is around where could it be called where lifecycle is controlled. There are a few problems:
I thought about other places this could go:
I haven't thought of a better place to add the listener than |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,7 +6,7 @@ | |
"license": "Apache-2.0", | ||
"main": "dist/commonjs/index.js", | ||
"module": "dist/es6/index.js", | ||
"sideEffects": false, | ||
"sideEffects": true, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should have always been |
||
"types": "dist/es6/index.d.ts", | ||
"repository": { | ||
"type": "git", | ||
|
@@ -41,5 +41,8 @@ | |
"canvas-kit", | ||
"workday", | ||
"popup-stack" | ||
] | ||
], | ||
"dependencies": { | ||
"screenfull": "^5.2.0" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import React from 'react'; | ||
import screenfull from 'screenfull'; | ||
|
||
export const useIsFullscreen = () => { | ||
const [isFullscreen, setIsFullscreen] = React.useState(false); | ||
|
||
const handler = React.useCallback(() => { | ||
setIsFullscreen(screenfull.isFullscreen); | ||
}, []); | ||
|
||
React.useEffect(() => { | ||
screenfull.on('change', handler); | ||
|
||
return () => { | ||
screenfull.off('change', handler); | ||
}; | ||
}, [handler]); | ||
|
||
return isFullscreen; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
screenfull was chosen because it is as lightweight as possible and already used by some teams.