solid-dismiss
Advanced tools
Comparing version 1.2.3 to 1.3.0
@@ -33,49 +33,97 @@ import { isServer, insert, template, delegateEvents, addEventListener, effect, setAttribute, classList, createComponent, mergeProps } from 'solid-js/web'; | ||
const onFocusFromOutsideAppOrTab = (state, e) => { | ||
const { | ||
containerEl, | ||
setOpen, | ||
onClickDocumentRef | ||
} = state; | ||
if (containerEl.contains(e.target)) return; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
state.prevFocusedEl = null; | ||
state.addedFocusOutAppEvents = false; | ||
document.removeEventListener("click", onClickDocumentRef); | ||
}; | ||
const onClickDocument = (state, e) => { | ||
const { | ||
containerEl, | ||
setOpen, | ||
onFocusFromOutsideAppOrTabRef | ||
} = state; | ||
if (!containerEl) return; | ||
if (containerEl.contains(e.target)) { | ||
state.addedFocusOutAppEvents = false; | ||
if (state.prevFocusedEl) { | ||
state.prevFocusedEl.removeEventListener("focus", onFocusFromOutsideAppOrTabRef); | ||
} | ||
state.prevFocusedEl = null; | ||
return; | ||
} | ||
if (state.prevFocusedEl) { | ||
state.prevFocusedEl.removeEventListener("focus", onFocusFromOutsideAppOrTabRef); | ||
} | ||
state.prevFocusedEl = null; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
state.addedFocusOutAppEvents = false; | ||
}; | ||
const removeOutsideFocusEvents = state => { | ||
const { | ||
onFocusFromOutsideAppOrTabRef, | ||
onClickDocumentRef | ||
} = state; | ||
if (!state.prevFocusedEl) return; | ||
state.prevFocusedEl.removeEventListener("focus", onFocusFromOutsideAppOrTabRef); | ||
document.removeEventListener("click", onClickDocumentRef); | ||
state.prevFocusedEl = null; | ||
state.addedFocusOutAppEvents = false; | ||
}; | ||
const _tabbableSelectors = ["a[href]", "area[href]", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "button:not([disabled])", "iframe", "[tabindex]", "[contentEditable=true]"].reduce((a, c, idx) => `${a}${idx ? "," : ""}${c}:not([tabindex="-1"])`, ""); | ||
let willWrap = false; | ||
let originalFrom = null; // TODO: does this work with web components, due to shadow root | ||
const getNextTabbableElement = ({ | ||
from = document.activeElement, | ||
stopAtElement, | ||
from: _from, | ||
stopAtRootElement: stopAtRootElement, | ||
ignoreElement = [], | ||
allowSelectors, | ||
direction = "forwards" | ||
direction = "forwards", | ||
wrap | ||
}) => { | ||
const parent = from.parentElement; | ||
const visitedElement = from; | ||
const tabbableSelectors = _tabbableSelectors + (allowSelectors ? "," + allowSelectors.join(",") : ""); | ||
if (!visitedElement) return null; | ||
let fromResult; | ||
let _isFromElIframe = false; | ||
const isHidden = (el, contentWindow = window) => { | ||
const checkByStyle = style => style.display === "none" || style.visibility === "hidden"; | ||
if (!(_from instanceof Element)) { | ||
if (_from === "activeElement") { | ||
const activeElement = document.activeElement; | ||
_isFromElIframe = isIframe(activeElement); | ||
fromResult = getActiveElement(activeElement); | ||
} | ||
if (el.style && checkByStyle(el.style) || el.hidden) return true; | ||
const style = contentWindow.getComputedStyle(el); | ||
if (!style || checkByStyle(style)) return true; | ||
return false; | ||
}; | ||
const checkHiddenAncestors = (target, parent, contentWindow) => { | ||
const ancestors = []; | ||
let node = target; | ||
if (isHidden(node)) return true; | ||
while (true) { | ||
node = node.parentElement; | ||
if (!node || node === parent) { | ||
break; | ||
if (typeof _from === "object") { | ||
if (_from.getActiveElement) { | ||
fromResult = getActiveElement(_from.el); | ||
} | ||
ancestors.push(node); | ||
_isFromElIframe = _from.isIframe; | ||
} | ||
} else { | ||
_isFromElIframe = isIframe(_from); | ||
fromResult = _from; | ||
} | ||
for (const node of ancestors) { | ||
if (isHidden(node, contentWindow)) { | ||
return true; | ||
} | ||
} | ||
const from = fromResult; | ||
const parent = from.parentElement; | ||
const isFromElIframe = _isFromElIframe; | ||
const visitedElement = from; | ||
const tabbableSelectors = _tabbableSelectors + (allowSelectors ? "," + allowSelectors.join(",") : ""); | ||
if (!visitedElement) return null; | ||
return false; | ||
}; | ||
const checkChildren = (children, parent, reverse, contentWindow) => { | ||
@@ -89,5 +137,6 @@ const length = children.length; | ||
if (ignoreElement.some(el => el.contains(child))) continue; | ||
if (originalFrom === child) continue; | ||
if (!checkHiddenAncestors(child, parent, contentWindow)) { | ||
if (child.tagName === "IFRAME") { | ||
if (isIframe(child)) { | ||
const iframeChild = queryIframe(child, reverse); | ||
@@ -107,5 +156,6 @@ if (iframeChild) return iframeChild; | ||
if (ignoreElement.some(el => el.contains(child))) continue; | ||
if (originalFrom === child) continue; | ||
if (!checkHiddenAncestors(child, parent, contentWindow)) { | ||
if (child.tagName === "IFRAME") { | ||
if (isIframe(child)) { | ||
const iframeChild = queryIframe(child); | ||
@@ -122,16 +172,11 @@ if (iframeChild) return iframeChild; | ||
const getIframeWindow = iframe => { | ||
try { | ||
return iframe.contentWindow; | ||
} catch (e) { | ||
return null; | ||
} | ||
}; | ||
const queryIframe = (el, inverseQuery) => { | ||
if (!el) return null; | ||
if (el.tagName !== "IFRAME") return el; | ||
const iframeWindow = getIframeWindow(el); | ||
const iframeDocument = iframeWindow.document; | ||
if (!isIframe(el)) return el; | ||
const iframeWindow = getIframeWindow(el); // here iframe will get focused whether it has tab index or not, so checking tabindex conditional a couple lines down is redundant | ||
if (!iframeWindow) return el; | ||
const iframeDocument = iframeWindow.document; // conditional used to be here | ||
// if (!iframeWindow) return el as HTMLElement; | ||
const tabindex = el.getAttribute("tabindex"); | ||
@@ -149,2 +194,6 @@ if (tabindex) return el; | ||
if (willWrap) { | ||
hasPassedVisitedElement = true; | ||
} | ||
if (direction === "forwards") { | ||
@@ -174,3 +223,3 @@ for (let i = 0; i < childrenCount; i++) { | ||
if (child === stopAtElement) { | ||
if (child === stopAtRootElement) { | ||
return null; | ||
@@ -204,3 +253,3 @@ } | ||
if (child === stopAtElement) { | ||
if (child === stopAtRootElement) { | ||
return null; | ||
@@ -218,367 +267,231 @@ } | ||
parent = parent.parentElement; | ||
if (!parent) return null; | ||
return traverseNextSiblingsThenUp(parent, visitedElement); | ||
}; | ||
const result = traverseNextSiblingsThenUp(parent, visitedElement); | ||
return result; | ||
}; | ||
if (!parent && isFromElIframe) { | ||
// TODO: only get's top level iframe, should get correct iframe | ||
const iframe = document.activeElement; | ||
let scrollEventAddedViaTouch = false; | ||
let scrollEventAdded = false; | ||
let pollTimeoutId = null; | ||
let timestampOfTabkey = 0; | ||
let cachedScrollTarget = null; | ||
let cachedPolledElement = null; | ||
const globalState = { | ||
closeByFocusSentinel: false, | ||
closedBySetOpen: false, | ||
addedDocumentClick: false, | ||
documentClickTimeout: null, | ||
closedByEvents: false, | ||
focusedMenuBtns: new Set() | ||
}; | ||
const onDocumentClick = e => { | ||
const target = e.target; | ||
checkThenClose(dismissStack, item => { | ||
if (item.overlay || item.overlayElement || getMenuButton(item.menuBtnEls).contains(target) || item.containerEl.contains(target)) return; | ||
return item; | ||
}, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
}); | ||
globalState.addedDocumentClick = false; | ||
}; | ||
const onWindowBlur = e => { | ||
const item = dismissStack[dismissStack.length - 1]; // menuPopup item was the last tabbable item in the document and current focused item is outside of document, such as browser URL bar, then menuPopup/stacks will close | ||
setTimeout(() => { | ||
const difference = e.timeStamp - timestampOfTabkey; | ||
if (!document.hasFocus()) { | ||
if (difference < 50) { | ||
checkThenClose(dismissStack, item => item, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
}); | ||
return; | ||
if (iframe && isIframe(iframe)) { | ||
visitedElement = iframe; | ||
parent = iframe.parentElement; | ||
} | ||
} | ||
}); | ||
const onBlurWindow = item => { | ||
if (item.overlay || item.overlayEl) return; | ||
if (!item.closeWhenDocumentBlurs) return; | ||
const menuBtnEl = getMenuButton(item.menuBtnEls); | ||
menuBtnEl.focus(); | ||
globalState.closedByEvents = true; | ||
item.setOpen(false); | ||
}; | ||
if (item.overlay) return; | ||
setTimeout(() => { | ||
const activeElement = document.activeElement; | ||
if (!activeElement || activeElement.tagName !== "IFRAME") { | ||
checkThenClose(dismissStack, item => item, item => onBlurWindow(item)); | ||
return; | ||
if (!parent) { | ||
return null; | ||
} | ||
checkThenClose(dismissStack, item => { | ||
const { | ||
containerEl | ||
} = item; | ||
return traverseNextSiblingsThenUp(parent, visitedElement); | ||
}; | ||
if (containerEl.contains(activeElement)) { | ||
cachedPolledElement = activeElement; | ||
pollingIframe(); | ||
document.addEventListener("visibilitychange", onVisibilityChange); | ||
return; | ||
} | ||
let result = traverseNextSiblingsThenUp(parent, visitedElement); | ||
return item; | ||
}, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
if (!result && wrap && stopAtRootElement) { | ||
// direction = direction === "forwards" ? "backwards" : "forwards"; | ||
willWrap = true; | ||
originalFrom = from; | ||
result = getNextTabbableElement({ | ||
from: stopAtRootElement, | ||
allowSelectors, | ||
direction, | ||
ignoreElement, | ||
// stopAtElement, | ||
wrap: false | ||
}); | ||
}); | ||
} | ||
willWrap = false; | ||
originalFrom = null; | ||
return result; | ||
}; | ||
const onKeyDown = e => { | ||
const { | ||
focusedMenuBtn, | ||
setOpen, | ||
menuBtnEls, | ||
cursorKeys, | ||
closeWhenEscapeKeyIsPressed, | ||
focusElementOnClose, | ||
timeouts | ||
} = dismissStack[dismissStack.length - 1]; | ||
if (e.key === "Tab") { | ||
timestampOfTabkey = e.timeStamp; | ||
const getIframeWindow = iframe => { | ||
try { | ||
return iframe.contentWindow; | ||
} catch (e) { | ||
return null; | ||
} | ||
}; | ||
if (cursorKeys) { | ||
onCursorKeys(e); | ||
} | ||
const getIframeDocument = iframe => { | ||
const iframeWindow = getIframeWindow(iframe); | ||
if (!iframeWindow) return null; | ||
return iframeWindow.document; | ||
}; | ||
if (e.key !== "Escape" || !closeWhenEscapeKeyIsPressed) return; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
const el = queryElement({}, { | ||
inputElement: focusElementOnClose, | ||
type: "focusElementOnClose", | ||
subType: "escapeKey" | ||
}) || menuBtnEl; | ||
const getActiveElement = el => { | ||
// TODO: only goes one depth, should go infinitly | ||
if (!isIframe(el)) return el; | ||
const iframeDocument = getIframeDocument(el); | ||
if (!iframeDocument) return el; | ||
return iframeDocument.activeElement || el; | ||
}; | ||
if (el) { | ||
el.focus(); | ||
const isHidden = (el, contentWindow = window) => { | ||
const checkByStyle = style => style.display === "none" || style.visibility === "hidden"; | ||
if (el === menuBtnEl) { | ||
markFocusedMenuButton({ | ||
focusedMenuBtn, | ||
timeouts, | ||
el | ||
}); | ||
} | ||
} | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
if (el.style && checkByStyle(el.style) || el.hidden) return true; | ||
const style = contentWindow.getComputedStyle(el); | ||
if (!style || checkByStyle(style)) return true; | ||
return false; | ||
}; | ||
const onScrollClose = e => { | ||
const target = e.target; | ||
if (cachedScrollTarget === target) return; | ||
checkThenClose(dismissStack, item => { | ||
const { | ||
menuPopupEl | ||
} = item; | ||
if (menuPopupEl.contains(target)) { | ||
cachedScrollTarget = target; | ||
return null; | ||
} | ||
const checkHiddenAncestors = (target, parent, contentWindow) => { | ||
const ancestors = []; | ||
let node = target; | ||
if (isHidden(node)) return true; | ||
return item; | ||
}, item => { | ||
const { | ||
setOpen, | ||
focusElementOnClose, | ||
menuBtnEls | ||
} = item; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
const el = queryElement({}, { | ||
inputElement: focusElementOnClose, | ||
type: "focusElementOnClose", | ||
subType: "scrolling" | ||
}) || menuBtnEl; | ||
while (true) { | ||
node = node.parentElement; | ||
if (el) { | ||
el.focus(); | ||
if (!node || node === parent) { | ||
break; | ||
} | ||
}); | ||
}; | ||
const addGlobalEvents = closeWhenScrolling => { | ||
cachedScrollTarget = null; | ||
if (!scrollEventAdded && closeWhenScrolling) { | ||
scrollEventAdded = false; | ||
window.addEventListener("wheel", onScrollClose, { | ||
capture: true, | ||
passive: true | ||
}); | ||
document.body.addEventListener("touchmove", onTouchMove); | ||
ancestors.push(node); | ||
} | ||
if (dismissStack.length) return; | ||
document.addEventListener("keydown", onKeyDown); | ||
window.addEventListener("blur", onWindowBlur); | ||
}; | ||
const removeGlobalEvents = () => { | ||
if (dismissStack.length) return; | ||
scrollEventAdded = false; | ||
globalState.addedDocumentClick = false; // globalState.menuBtnEl = null; | ||
for (const node of ancestors) { | ||
if (isHidden(node, contentWindow)) { | ||
return true; | ||
} | ||
} | ||
window.clearTimeout(globalState.documentClickTimeout); | ||
globalState.documentClickTimeout = null; | ||
document.removeEventListener("keydown", onKeyDown); | ||
document.removeEventListener("click", onDocumentClick); | ||
window.removeEventListener("blur", onWindowBlur); | ||
window.removeEventListener("wheel", onScrollClose, { | ||
capture: true | ||
}); | ||
document.body.removeEventListener("touchmove", onTouchMove); | ||
return false; | ||
}; | ||
const onTouchMove = () => { | ||
if (scrollEventAddedViaTouch) return; | ||
scrollEventAddedViaTouch = true; | ||
document.body.addEventListener("touchend", () => { | ||
scrollEventAddedViaTouch = false; | ||
}, { | ||
once: true | ||
}); | ||
window.addEventListener("scroll", onScrollClose, { | ||
capture: true, | ||
passive: true, | ||
once: true | ||
}); | ||
const isIframe = el => el.tagName === "IFRAME"; | ||
/** | ||
* Iterate stack backwards, checks item, pass it to close callback. First falsy value breaks iteration. | ||
*/ | ||
const checkThenClose = (arr, checkCb, destroyCb) => { | ||
for (let i = arr.length - 1; i >= 0; i--) { | ||
const item = checkCb(arr[i]); | ||
if (item) { | ||
destroyCb(item); | ||
continue; | ||
} | ||
return; | ||
} | ||
}; | ||
const onCursorKeys = e => { | ||
const keys = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]; | ||
const horizontalKeys = ["ArrowLeft", "ArrowRight"]; | ||
if (!keys.includes(e.key)) return; | ||
e.preventDefault(); | ||
if (horizontalKeys.includes(e.key)) return; | ||
const { | ||
menuBtnEls, | ||
menuPopupEl, | ||
containerEl, | ||
focusSentinelBeforeEl | ||
} = dismissStack[dismissStack.length - 1]; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
let activeElement = document.activeElement; | ||
let direction; | ||
/** | ||
* Why this might be better than direct check of CSS display property? Because you do not need to check all parent elements. If some parent element has display: none, its children are hidden too but still has `element.style.display !== 'none'` | ||
*/ | ||
const hasDisplayNone = el => el.offsetHeight === 0 && el.offsetWidth === 0; | ||
if (e.key === "ArrowDown") { | ||
direction = "forwards"; | ||
} else { | ||
direction = "backwards"; | ||
const queryElement = (state, { | ||
inputElement, | ||
type, | ||
subType | ||
}) => { | ||
if (inputElement === "menuPopup") { | ||
return state.menuPopupEl; | ||
} | ||
if (activeElement === menuBtnEl || activeElement === menuPopupEl || activeElement === containerEl) { | ||
direction = "forwards"; | ||
activeElement = focusSentinelBeforeEl; | ||
if (inputElement === "menuButton") { | ||
return getMenuButton(state.menuBtnEls); | ||
} | ||
const el = getNextTabbableElement({ | ||
from: activeElement, | ||
direction, | ||
stopAtElement: menuPopupEl | ||
}); | ||
if (type === "focusElementOnOpen") { | ||
if (inputElement === "firstChild") { | ||
return getNextTabbableElement({ | ||
from: state.focusSentinelBeforeEl, | ||
stopAtRootElement: state.containerEl | ||
}); | ||
} | ||
if (el) { | ||
el.focus(); | ||
if (typeof inputElement === "string") { | ||
return state.containerEl?.querySelector(inputElement); | ||
} | ||
if (inputElement instanceof Element) { | ||
return inputElement; | ||
} | ||
return inputElement(); | ||
} | ||
}; | ||
const onVisibilityChange = () => { | ||
if (document.visibilityState === "visible" && pollTimeoutId != null) { | ||
pollingIframe(); | ||
return; | ||
if (inputElement == null && type === "menuPopup") { | ||
if (!state.containerEl) return null; | ||
if (state.menuPopupEl) return state.menuPopupEl; | ||
return state.containerEl.children[1]; | ||
} | ||
clearTimeout(pollTimeoutId); | ||
}; // polls iframe to deal with edge case if menuPopup item selected is an iframe and then select another iframe that is "outside" of menuPopup | ||
if (typeof inputElement === "string" && type === "menuButton") { | ||
return document.querySelector(inputElement); | ||
} | ||
if (typeof inputElement === "string") { | ||
return document.querySelector(inputElement); | ||
} | ||
const pollingIframe = () => { | ||
// worst case scenerio is user has to wait for up to 250ms for menuPopup to close, while average case is 125ms | ||
const duration = 250; | ||
if (inputElement instanceof Element) { | ||
return inputElement; | ||
} | ||
const poll = () => { | ||
const activeElement = document.activeElement; | ||
if (typeof inputElement === "function") { | ||
const result = inputElement(); | ||
if (!activeElement) { | ||
return; | ||
if (result instanceof Element) { | ||
return result; | ||
} | ||
if (cachedPolledElement === activeElement) { | ||
pollTimeoutId = window.setTimeout(poll, duration); | ||
return; | ||
if (type === "closeButton") { | ||
if (!state.containerEl) return null; | ||
return state.containerEl.querySelector(result); | ||
} | ||
} | ||
checkThenClose(dismissStack, item => { | ||
const { | ||
containerEl | ||
} = item; | ||
if (type === "focusElementOnClose") { | ||
if (!inputElement) return null; | ||
if (activeElement.tagName === "IFRAME") { | ||
if (containerEl && !containerEl.contains(activeElement)) { | ||
return item; | ||
} | ||
switch (subType) { | ||
case "tabForwards": | ||
return queryElement(state, { | ||
inputElement: inputElement.tabForwards | ||
}); | ||
cachedPolledElement = activeElement; | ||
pollTimeoutId = window.setTimeout(poll, duration); | ||
} | ||
case "tabBackwards": | ||
return queryElement(state, { | ||
inputElement: inputElement.tabBackwards | ||
}); | ||
return; | ||
}, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
cachedPolledElement = null; | ||
pollTimeoutId = null; | ||
document.removeEventListener("visibilitychange", onVisibilityChange); | ||
}); | ||
}; | ||
case "click": | ||
return queryElement(state, { | ||
inputElement: inputElement.click | ||
}); | ||
pollTimeoutId = window.setTimeout(poll, duration); | ||
}; | ||
case "escapeKey": | ||
return queryElement(state, { | ||
inputElement: inputElement.escapeKey | ||
}); | ||
const onFocusFromOutsideAppOrTab = (state, e) => { | ||
const { | ||
containerEl, | ||
setOpen, | ||
onClickDocumentRef | ||
} = state; | ||
if (containerEl.contains(e.target)) return; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
state.prevFocusedEl = null; | ||
state.addedFocusOutAppEvents = false; | ||
document.removeEventListener("click", onClickDocumentRef); | ||
}; | ||
const onClickDocument = (state, e) => { | ||
const { | ||
containerEl, | ||
setOpen, | ||
onFocusFromOutsideAppOrTabRef | ||
} = state; | ||
if (!containerEl) return; | ||
case "scrolling": | ||
return queryElement(state, { | ||
inputElement: inputElement.scrolling | ||
}); | ||
} | ||
} | ||
if (containerEl.contains(e.target)) { | ||
state.addedFocusOutAppEvents = false; | ||
if (inputElement == null) return null; | ||
if (state.prevFocusedEl) { | ||
state.prevFocusedEl.removeEventListener("focus", onFocusFromOutsideAppOrTabRef); | ||
} | ||
state.prevFocusedEl = null; | ||
return; | ||
if (Array.isArray(inputElement)) { | ||
return inputElement.map(el => queryElement(state, { | ||
inputElement: el, | ||
type | ||
})); | ||
} | ||
if (state.prevFocusedEl) { | ||
state.prevFocusedEl.removeEventListener("focus", onFocusFromOutsideAppOrTabRef); | ||
for (const key in inputElement) { | ||
const item = inputElement[key]; | ||
return queryElement(state, { | ||
inputElement: item | ||
}); | ||
} | ||
state.prevFocusedEl = null; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
state.addedFocusOutAppEvents = false; | ||
return null; | ||
}; | ||
const removeOutsideFocusEvents = state => { | ||
const { | ||
onFocusFromOutsideAppOrTabRef, | ||
onClickDocumentRef | ||
} = state; | ||
if (!state.prevFocusedEl) return; | ||
state.prevFocusedEl.removeEventListener("focus", onFocusFromOutsideAppOrTabRef); | ||
document.removeEventListener("click", onClickDocumentRef); | ||
state.prevFocusedEl = null; | ||
state.addedFocusOutAppEvents = false; | ||
}; | ||
@@ -593,3 +506,4 @@ let mousedownFired = false; | ||
setOpen, | ||
open | ||
open, | ||
deadMenuButton | ||
} = state; | ||
@@ -605,2 +519,12 @@ const menuBtnEl = e.currentTarget; | ||
if (deadMenuButton) { | ||
globalState.addedDocumentClick = true; | ||
setTimeout(() => { | ||
document.addEventListener("click", onDocumentClick, { | ||
once: true | ||
}); | ||
}); | ||
return; | ||
} | ||
state.menuBtnKeyupTabFired = false; | ||
@@ -718,3 +642,4 @@ | ||
focusSentinelBeforeEl, | ||
focusSentinelAfterEl | ||
focusSentinelAfterEl, | ||
ignoreMenuPopupWhenTabbing | ||
} = state; | ||
@@ -750,5 +675,23 @@ const menuBtnEl = e.currentTarget; | ||
e.preventDefault(); | ||
if (ignoreMenuPopupWhenTabbing) { | ||
const el = getNextTabbableElement({ | ||
from: menuBtnEl, | ||
direction: "forwards", | ||
ignoreElement: [containerEl, focusSentinelBeforeEl, focusSentinelAfterEl] | ||
}); | ||
if (el) { | ||
el.focus(); | ||
} | ||
setOpen(false); | ||
menuBtnEl.removeEventListener("keydown", onKeydownMenuButtonRef); | ||
menuBtnEl.removeEventListener("blur", onBlurMenuButtonRef); | ||
return; | ||
} | ||
let el = getNextTabbableElement({ | ||
from: focusSentinelBeforeEl, | ||
stopAtElement: containerEl | ||
stopAtRootElement: containerEl | ||
}); | ||
@@ -779,5 +722,20 @@ | ||
closeWhenMenuButtonIsTabbed, | ||
timeouts | ||
timeouts, | ||
deadMenuButton, | ||
menuBtnEls | ||
} = state; | ||
if (deadMenuButton) { | ||
const menuBtn = getMenuButton(menuBtnEls); | ||
menuBtn.addEventListener("blur", state.onBlurMenuButtonRef); | ||
menuBtn.addEventListener("keydown", state.onKeydownMenuButtonRef); | ||
globalState.addedDocumentClick = true; | ||
setTimeout(() => { | ||
document.addEventListener("click", onDocumentClick, { | ||
once: true | ||
}); | ||
}); | ||
return; | ||
} | ||
if (!closeWhenMenuButtonIsTabbed) { | ||
@@ -812,10 +770,66 @@ clearTimeout(timeouts.containerFocusTimeoutId); | ||
}; | ||
const removeMenuButtonEvents = (state, onCleanup) => { | ||
const addMenuButtonEventsAndAttr = ({ | ||
state, | ||
menuButton, | ||
open | ||
}) => { | ||
if (Array.isArray(menuButton) && !menuButton.length) return; | ||
const { | ||
focusedMenuBtn | ||
} = state; | ||
const menuBtnEls = queryElement(state, { | ||
inputElement: menuButton, | ||
type: "menuButton" | ||
}); | ||
if (!menuBtnEls) { | ||
return; | ||
} | ||
state.menuBtnEls = Array.isArray(menuBtnEls) ? menuBtnEls : [menuBtnEls]; | ||
const item = dismissStack.find(item => item.uniqueId === state.uniqueId); | ||
if (item) { | ||
item.menuBtnEls = state.menuBtnEls; | ||
} | ||
if (state.deadMenuButton) { | ||
state.menuBtnEls.forEach(menuBtnEl => { | ||
menuBtnEl.addEventListener("click", state.onClickMenuButtonRef); | ||
menuBtnEl.addEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
menuBtnEl.addEventListener("focus", state.onFocusMenuButtonRef); | ||
}); | ||
return; | ||
} | ||
state.menuBtnEls.forEach((menuBtnEl, _, self) => { | ||
if (focusedMenuBtn.el && focusedMenuBtn.el !== menuBtnEl && (self.length > 1 ? !hasDisplayNone(menuBtnEl) : true)) { | ||
focusedMenuBtn.el = menuBtnEl; | ||
menuBtnEl.focus({ | ||
preventScroll: true | ||
}); | ||
menuBtnEl.addEventListener("keydown", state.onKeydownMenuButtonRef); | ||
} | ||
menuBtnEl.setAttribute("type", "button"); | ||
menuBtnEl.addEventListener("click", state.onClickMenuButtonRef); | ||
menuBtnEl.addEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
if (open() && (!state.focusElementOnOpen || state.focusElementOnOpen === "menuButton" || state.focusElementOnOpen === state.menuBtnEls) && !hasDisplayNone(menuBtnEl)) { | ||
menuBtnEl.addEventListener("blur", state.onBlurMenuButtonRef, { | ||
once: true | ||
}); | ||
} | ||
}); | ||
}; | ||
const removeMenuButtonEvents = (state, isCleanup) => { | ||
if (!state || !state.menuBtnEls) return; | ||
state.menuBtnEls.forEach(menuBtnEl => { | ||
menuBtnEl.removeEventListener("focus", state.onFocusMenuButtonRef); // menuBtnEl.removeEventListener("keydown", state.onKeydownMenuButtonRef); | ||
if (!state.deadMenuButton) { | ||
menuBtnEl.removeEventListener("focus", state.onFocusMenuButtonRef); | ||
} | ||
menuBtnEl.removeEventListener("blur", state.onBlurMenuButtonRef); | ||
if (onCleanup) { | ||
if (isCleanup) { | ||
menuBtnEl.removeEventListener("click", state.onClickMenuButtonRef); | ||
@@ -827,156 +841,359 @@ menuBtnEl.removeEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
/** | ||
* Iterate stack backwards, checks item, pass it close callback. First falsy value breaks iteration. | ||
*/ | ||
let scrollEventAddedViaTouch = false; | ||
let scrollEventAdded = false; | ||
let pollTimeoutId = null; | ||
let timestampOfTabkey = 0; | ||
let cachedScrollTarget = null; | ||
let cachedPolledElement = null; | ||
const globalState = { | ||
closeByFocusSentinel: false, | ||
closedBySetOpen: false, | ||
addedDocumentClick: false, | ||
documentClickTimeout: null, | ||
closedByEvents: false, | ||
focusedMenuBtns: new Set(), | ||
cursorKeysPrevEl: null | ||
}; | ||
const onDocumentClick = e => { | ||
const target = e.target; | ||
checkThenClose(dismissStack, item => { | ||
const menuButton = getMenuButton(item.menuBtnEls); | ||
if (item.overlay || item.overlayElement || menuButton && menuButton.contains(target) || item.containerEl.contains(target)) return; | ||
return item; | ||
}, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
}); | ||
globalState.addedDocumentClick = false; | ||
}; | ||
const onWindowBlur = e => { | ||
const item = dismissStack[dismissStack.length - 1]; // menuPopup item was the last tabbable item in the document and current focused item is outside of document, such as browser URL bar, then menuPopup/stacks will close | ||
const checkThenClose = (arr, checkCb, destroyCb) => { | ||
for (let i = arr.length - 1; i >= 0; i--) { | ||
const item = checkCb(arr[i]); | ||
setTimeout(() => { | ||
const difference = e.timeStamp - timestampOfTabkey; | ||
if (item) { | ||
destroyCb(item); | ||
continue; | ||
if (!document.hasFocus()) { | ||
if (difference < 50) { | ||
checkThenClose(dismissStack, item => item, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
}); | ||
return; | ||
} | ||
} | ||
}); | ||
return; | ||
} | ||
}; | ||
const camelize = s => s.replace(/-./g, x => x.toUpperCase()[1]); | ||
const matchByFirstChild = ({ | ||
parent, | ||
matchEl | ||
}) => { | ||
if (parent === matchEl) return true; | ||
const onBlurWindow = item => { | ||
if (item.overlay || item.overlayEl) return; | ||
if (!item.closeWhenDocumentBlurs) return; | ||
const menuBtnEl = getMenuButton(item.menuBtnEls); | ||
menuBtnEl.focus(); | ||
globalState.closedByEvents = true; | ||
item.setOpen(false); | ||
}; | ||
const query = el => { | ||
if (!el) return false; | ||
const child = el.children[0]; | ||
if (item.overlay) return; | ||
setTimeout(() => { | ||
const activeElement = document.activeElement; | ||
if (child === matchEl) { | ||
return true; | ||
if (!activeElement || activeElement.tagName !== "IFRAME") { | ||
checkThenClose(dismissStack, item => item, item => onBlurWindow(item)); | ||
return; | ||
} | ||
return query(child); | ||
}; | ||
checkThenClose(dismissStack, item => { | ||
const { | ||
containerEl | ||
} = item; | ||
return query(parent); | ||
if (containerEl.contains(activeElement)) { | ||
cachedPolledElement = activeElement; | ||
pollingIframe(); | ||
document.addEventListener("visibilitychange", onVisibilityChange); | ||
return; | ||
} | ||
return item; | ||
}, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
}); | ||
}); | ||
}; | ||
const queryElement = (state, { | ||
inputElement, | ||
type, | ||
subType | ||
}) => { | ||
if (inputElement === "menuPopup") { | ||
return state.menuPopupEl; | ||
} | ||
const onKeyDown = e => { | ||
const { | ||
focusedMenuBtn, | ||
setOpen, | ||
menuBtnEls, | ||
cursorKeys, | ||
closeWhenEscapeKeyIsPressed, | ||
focusElementOnClose, | ||
timeouts, | ||
ignoreMenuPopupWhenTabbing, | ||
focusSentinelAfterEl, | ||
focusSentinelBeforeEl | ||
} = dismissStack[dismissStack.length - 1]; | ||
if (inputElement === "menuButton") { | ||
return getMenuButton(state.menuBtnEls); | ||
} | ||
if (e.key === "Tab") { | ||
if (ignoreMenuPopupWhenTabbing) { | ||
e.preventDefault(); | ||
const shiftKey = e.shiftKey; // TODO: work with stacks? | ||
if (type === "focusElementOnOpen") { | ||
if (inputElement === "firstChild") { | ||
return getNextTabbableElement({ | ||
from: state.focusSentinelBeforeEl, | ||
stopAtElement: state.containerEl | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
const el = getNextTabbableElement({ | ||
from: shiftKey ? focusSentinelBeforeEl : focusSentinelAfterEl, | ||
direction: shiftKey ? "backwards" : "forwards", | ||
ignoreElement: menuBtnEl ? [menuBtnEl] : [] | ||
}); | ||
} | ||
if (typeof inputElement === "string") { | ||
return state.containerEl?.querySelector(inputElement); | ||
} | ||
if (el) { | ||
el.focus(); | ||
} | ||
if (inputElement instanceof Element) { | ||
return inputElement; | ||
return; | ||
} | ||
return inputElement(); | ||
timestampOfTabkey = e.timeStamp; | ||
} | ||
if (inputElement == null && type === "menuPopup") { | ||
if (!state.containerEl) return null; | ||
if (state.menuPopupEl) return state.menuPopupEl; | ||
return state.containerEl.children[1]; | ||
if (cursorKeys) { | ||
onCursorKeys(e); | ||
} | ||
if (typeof inputElement === "string" && type === "menuButton") { | ||
return document.querySelector(inputElement); | ||
} | ||
if (e.key !== "Escape" || !closeWhenEscapeKeyIsPressed) return; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
const el = queryElement({}, { | ||
inputElement: focusElementOnClose, | ||
type: "focusElementOnClose", | ||
subType: "escapeKey" | ||
}) || menuBtnEl; | ||
if (typeof inputElement === "string") { | ||
return document.querySelector(inputElement); | ||
} | ||
if (el) { | ||
el.focus(); | ||
if (inputElement instanceof Element) { | ||
return inputElement; | ||
if (el === menuBtnEl) { | ||
markFocusedMenuButton({ | ||
focusedMenuBtn, | ||
timeouts, | ||
el | ||
}); | ||
} | ||
} | ||
if (typeof inputElement === "function") { | ||
const result = inputElement(); | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
}; | ||
const onScrollClose = e => { | ||
const target = e.target; | ||
if (cachedScrollTarget === target) return; | ||
checkThenClose(dismissStack, item => { | ||
const { | ||
menuPopupEl | ||
} = item; | ||
if (result instanceof Element) { | ||
return result; | ||
if (menuPopupEl.contains(target)) { | ||
cachedScrollTarget = target; | ||
return null; | ||
} | ||
if (type === "closeButton") { | ||
if (!state.containerEl) return null; | ||
return state.containerEl.querySelector(result); | ||
return item; | ||
}, item => { | ||
const { | ||
setOpen, | ||
focusElementOnClose, | ||
menuBtnEls | ||
} = item; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
const el = queryElement({}, { | ||
inputElement: focusElementOnClose, | ||
type: "focusElementOnClose", | ||
subType: "scrolling" | ||
}) || menuBtnEl; | ||
if (el) { | ||
el.focus(); | ||
} | ||
}); | ||
}; | ||
const addGlobalEvents = closeWhenScrolling => { | ||
cachedScrollTarget = null; | ||
if (!scrollEventAdded && closeWhenScrolling) { | ||
scrollEventAdded = false; | ||
window.addEventListener("wheel", onScrollClose, { | ||
capture: true, | ||
passive: true | ||
}); | ||
document.body.addEventListener("touchmove", onTouchMove); | ||
} | ||
if (type === "focusElementOnClose") { | ||
if (!inputElement) return null; | ||
if (dismissStack.length) return; | ||
document.addEventListener("keydown", onKeyDown); | ||
window.addEventListener("blur", onWindowBlur); | ||
}; | ||
const removeGlobalEvents = () => { | ||
if (dismissStack.length) return; | ||
scrollEventAdded = false; | ||
globalState.addedDocumentClick = false; | ||
globalState.cursorKeysPrevEl = null; // globalState.menuBtnEl = null; | ||
switch (subType) { | ||
case "tabForwards": | ||
return queryElement(state, { | ||
inputElement: inputElement.tabForwards | ||
}); | ||
window.clearTimeout(globalState.documentClickTimeout); | ||
globalState.documentClickTimeout = null; | ||
document.removeEventListener("keydown", onKeyDown); | ||
document.removeEventListener("click", onDocumentClick); | ||
window.removeEventListener("blur", onWindowBlur); | ||
window.removeEventListener("wheel", onScrollClose, { | ||
capture: true | ||
}); | ||
document.body.removeEventListener("touchmove", onTouchMove); | ||
}; | ||
case "tabBackwards": | ||
return queryElement(state, { | ||
inputElement: inputElement.tabBackwards | ||
}); | ||
const onTouchMove = () => { | ||
if (scrollEventAddedViaTouch) return; | ||
scrollEventAddedViaTouch = true; | ||
document.body.addEventListener("touchend", () => { | ||
scrollEventAddedViaTouch = false; | ||
}, { | ||
once: true | ||
}); | ||
window.addEventListener("scroll", onScrollClose, { | ||
capture: true, | ||
passive: true, | ||
once: true | ||
}); | ||
}; | ||
case "click": | ||
return queryElement(state, { | ||
inputElement: inputElement.click | ||
}); | ||
const onCursorKeys = e => { | ||
const keys = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]; | ||
const horizontalKeys = ["ArrowLeft", "ArrowRight"]; | ||
if (!keys.includes(e.key)) return; | ||
e.preventDefault(); | ||
if (horizontalKeys.includes(e.key)) return; | ||
const { | ||
menuBtnEls, | ||
menuPopupEl, | ||
containerEl, | ||
focusSentinelBeforeEl, | ||
focusSentinelAfterEl, | ||
cursorKeys | ||
} = dismissStack[dismissStack.length - 1]; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
let activeElement = globalState.cursorKeysPrevEl || document.activeElement; | ||
let direction; | ||
case "escapeKey": | ||
return queryElement(state, { | ||
inputElement: inputElement.escapeKey | ||
}); | ||
if (e.key === "ArrowDown") { | ||
direction = "forwards"; | ||
} else { | ||
direction = "backwards"; | ||
} | ||
case "scrolling": | ||
return queryElement(state, { | ||
inputElement: inputElement.scrolling | ||
}); | ||
if (activeElement === menuBtnEl || activeElement === menuPopupEl || activeElement === containerEl) { | ||
if (e.key === "ArrowUp") { | ||
direction = "backwards"; | ||
activeElement = focusSentinelAfterEl; | ||
} else { | ||
direction = "forwards"; | ||
activeElement = focusSentinelBeforeEl; | ||
} | ||
} | ||
if (inputElement == null) return null; | ||
const isCursorKeysArgObj = typeof cursorKeys === "object"; | ||
const willWrap = isCursorKeysArgObj && cursorKeys.wrap; | ||
let el = getNextTabbableElement({ | ||
from: activeElement, | ||
direction, | ||
stopAtRootElement: menuPopupEl | ||
}); | ||
if (Array.isArray(inputElement)) { | ||
return inputElement.map(el => queryElement(state, { | ||
inputElement: el, | ||
type | ||
})); | ||
if (!el && willWrap) { | ||
const from = e.key === "ArrowDown" ? focusSentinelBeforeEl : focusSentinelAfterEl; | ||
direction = e.key === "ArrowDown" ? "forwards" : "backwards"; | ||
el = getNextTabbableElement({ | ||
from, | ||
direction, | ||
stopAtRootElement: containerEl | ||
}); | ||
} | ||
for (const key in inputElement) { | ||
const item = inputElement[key]; | ||
return queryElement(state, { | ||
inputElement: item | ||
if (isCursorKeysArgObj && cursorKeys.onKeyDown) { | ||
cursorKeys.onKeyDown({ | ||
currentEl: el, | ||
prevEl: globalState.cursorKeysPrevEl | ||
}); | ||
globalState.cursorKeysPrevEl = el; | ||
return; | ||
} | ||
return null; | ||
if (el) { | ||
el.focus(); | ||
} | ||
}; | ||
/** | ||
* Why this might be better than direct check of CSS display property? Because you do not need to check all parent elements. If some parent element has display: none, its children are hidden too but still has `element.style.display !== 'none'` | ||
*/ | ||
const hasDisplayNone = el => el.offsetHeight === 0 && el.offsetWidth === 0; | ||
const onVisibilityChange = () => { | ||
if (document.visibilityState === "visible" && pollTimeoutId != null) { | ||
pollingIframe(); | ||
return; | ||
} | ||
clearTimeout(pollTimeoutId); | ||
}; // polls iframe to deal with edge case if menuPopup item selected is an iframe and then select another iframe that is "outside" of menuPopup | ||
const pollingIframe = () => { | ||
// worst case scenerio is user has to wait for up to 250ms for menuPopup to close, while average case is 125ms | ||
const duration = 250; | ||
const poll = () => { | ||
const activeElement = document.activeElement; | ||
if (!activeElement) { | ||
return; | ||
} | ||
if (cachedPolledElement === activeElement) { | ||
pollTimeoutId = window.setTimeout(poll, duration); | ||
return; | ||
} | ||
checkThenClose(dismissStack, item => { | ||
const { | ||
containerEl | ||
} = item; | ||
if (activeElement.tagName === "IFRAME") { | ||
if (containerEl && !containerEl.contains(activeElement)) { | ||
return item; | ||
} | ||
cachedPolledElement = activeElement; | ||
pollTimeoutId = window.setTimeout(poll, duration); | ||
} | ||
return; | ||
}, item => { | ||
const { | ||
setOpen | ||
} = item; | ||
globalState.closedByEvents = true; | ||
setOpen(false); | ||
cachedPolledElement = null; | ||
pollTimeoutId = null; | ||
document.removeEventListener("visibilitychange", onVisibilityChange); | ||
}); | ||
}; | ||
pollTimeoutId = window.setTimeout(poll, duration); | ||
}; | ||
const addMenuPopupEl = state => { | ||
@@ -1051,2 +1268,4 @@ const { | ||
const camelize = s => s.replace(/-./g, x => x.toUpperCase()[1]); | ||
const Transition = props => { | ||
@@ -1178,8 +1397,9 @@ let el; | ||
const removeLocalEvents = (state, { | ||
onCleanup = false | ||
isCleanup = false | ||
} = {}) => { | ||
document.removeEventListener("click", state.onClickDocumentRef); | ||
removeMenuButtonEvents(state, onCleanup); | ||
removeMenuButtonEvents(state, isCleanup); | ||
}; | ||
// Safari, if relatedTarget is not contained within focusout, it will be null | ||
const onFocusOutContainer = (state, e) => { | ||
@@ -1194,3 +1414,5 @@ const { | ||
stopComponentEventPropagation, | ||
focusedMenuBtn | ||
focusedMenuBtn, | ||
menuButton, | ||
deadMenuButton | ||
} = state; | ||
@@ -1230,2 +1452,6 @@ const relatedTarget = e.relatedTarget; | ||
if (relatedTarget === menuButton && deadMenuButton) { | ||
return; | ||
} | ||
timeouts.containerFocusTimeoutId = window.setTimeout(() => { | ||
@@ -1249,3 +1475,7 @@ globalState.closedByEvents = true; | ||
} = state; | ||
if (focusElementOnOpen == null) return; | ||
if (focusElementOnOpen == null) { | ||
return; | ||
} | ||
const el = queryElement(state, { | ||
@@ -1302,2 +1532,22 @@ inputElement: focusElementOnOpen, | ||
const matchByFirstChild = ({ | ||
parent, | ||
matchEl | ||
}) => { | ||
if (parent === matchEl) return true; | ||
const query = el => { | ||
if (!el) return false; | ||
const child = el.children[0]; | ||
if (child === matchEl) { | ||
return true; | ||
} | ||
return query(child); | ||
}; | ||
return query(parent); | ||
}; | ||
const activateLastFocusSentinel = state => { | ||
@@ -1309,4 +1559,11 @@ const { | ||
focusSentinelAfterEl | ||
} = state; | ||
if (enableLastFocusSentinel) return; | ||
} = state; // Before | ||
// if (enableLastFocusSentinel) return; | ||
if (enableLastFocusSentinel) { | ||
focusSentinelAfterEl.setAttribute("tabindex", "0"); | ||
return; | ||
} | ||
if (!menuBtnEls) return; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
@@ -1372,6 +1629,7 @@ const menuBtnSibling = menuBtnEl.nextElementSibling; | ||
if (relatedTarget === containerEl || relatedTarget === menuBtnEl) { | ||
if (menuBtnEl && (relatedTarget === containerEl || relatedTarget === menuBtnEl)) { | ||
const el = getNextTabbableElement({ | ||
from: focusSentinelBeforeEl, | ||
stopAtElement: containerEl | ||
direction: "forwards", | ||
stopAtRootElement: containerEl | ||
}); | ||
@@ -1387,3 +1645,3 @@ el.focus(); | ||
direction: "backwards", | ||
stopAtElement: containerEl | ||
stopAtRootElement: containerEl | ||
}); | ||
@@ -1405,4 +1663,15 @@ el.focus(); | ||
subType: "tabBackwards" | ||
}) || menuBtnEl; | ||
runIfMounted(el, true); | ||
}) || menuBtnEl; // conditional and early return just for no button feature | ||
if (!state.menuBtnEls) { | ||
// this wasn't here before | ||
el.focus(); | ||
return; | ||
} // this wasn't wrapped with mount condition when it should be | ||
// if (mount) { | ||
runIfMounted(el, true); // } | ||
// so | ||
return; | ||
@@ -1414,3 +1683,3 @@ } | ||
from: focusSentinelBeforeEl, | ||
stopAtElement: containerEl | ||
stopAtRootElement: containerEl | ||
}); | ||
@@ -1445,2 +1714,18 @@ el.focus(); | ||
_tmpl$2 = template(`<div><div style="position: fixed; top: 0; left: 0; outline: none; pointer-events: none; width: 0; height: 0;" aria-hidden="true"></div><div style="position: fixed; top: 0; left: 0; outline: none; pointer-events: none; width: 0; height: 0;" aria-hidden="true"></div></div>`, 6); | ||
/** | ||
* ### stack popup bug | ||
* | ||
* 1. open 4 stacks | ||
* 2. click 3rd stack, which removes 1 stack (topmost), leaving 3 stacks left | ||
* 3. click outside of all stacks | ||
* | ||
* ## Expected | ||
* | ||
* To close all stacks (remaining 2 stacks), due to none of the stacks containing new focused element, cuz you clicked outside of all stacks | ||
* | ||
* ## Actual Result | ||
* | ||
* Only the 3rd stack (topmost) gets removed. All stacks should have been removed. Clicking ou and nothing happens. | ||
* | ||
*/ | ||
@@ -1452,4 +1737,3 @@ /** | ||
const Dismiss = props => { | ||
const modal = props.modal || false; // @ts-check | ||
const modal = props.modal || false; | ||
const { | ||
@@ -1477,3 +1761,5 @@ id, | ||
onToggleScrollbar, | ||
onOpen | ||
onOpen, | ||
deadMenuButton, | ||
ignoreMenuPopupWhenTabbing | ||
} = props; | ||
@@ -1491,3 +1777,5 @@ const state = { | ||
focusElementOnClose, | ||
deadMenuButton, | ||
focusElementOnOpen, | ||
ignoreMenuPopupWhenTabbing, | ||
// @ts-ignore | ||
@@ -1845,39 +2133,9 @@ id, | ||
createEffect(on(() => typeof props.menuButton === "function" ? props.menuButton() : props.menuButton, menuButton => { | ||
if (Array.isArray(menuButton) && !menuButton.length) return; | ||
const { | ||
focusedMenuBtn | ||
} = state; | ||
const menuBtnEls = queryElement(state, { | ||
inputElement: menuButton, | ||
type: "menuButton" | ||
addMenuButtonEventsAndAttr({ | ||
state, | ||
menuButton, | ||
open: props.open | ||
}); | ||
if (!menuBtnEls) return; | ||
state.menuBtnEls = Array.isArray(menuBtnEls) ? menuBtnEls : [menuBtnEls]; | ||
state.menuBtnEls.forEach((menuBtnEl, _, self) => { | ||
if (focusedMenuBtn.el && focusedMenuBtn.el !== menuBtnEl && (self.length > 1 ? !hasDisplayNone(menuBtnEl) : true)) { | ||
focusedMenuBtn.el = menuBtnEl; | ||
menuBtnEl.focus({ | ||
preventScroll: true | ||
}); | ||
menuBtnEl.addEventListener("keydown", state.onKeydownMenuButtonRef); | ||
} | ||
menuBtnEl.setAttribute("type", "button"); | ||
menuBtnEl.addEventListener("click", state.onClickMenuButtonRef); | ||
menuBtnEl.addEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
if (props.open() && (!state.focusElementOnOpen || state.focusElementOnOpen === "menuButton" || state.focusElementOnOpen === state.menuBtnEls) && !hasDisplayNone(menuBtnEl)) { | ||
menuBtnEl.addEventListener("blur", state.onBlurMenuButtonRef, { | ||
once: true | ||
}); | ||
} | ||
}); | ||
const item = dismissStack.find(item => item.uniqueId === state.uniqueId); | ||
if (item) { | ||
item.menuBtnEls = state.menuBtnEls; | ||
} | ||
onCleanup(() => { | ||
if (!state) return; | ||
if (!state || isServer) return; | ||
removeMenuButtonEvents(state, true); | ||
@@ -1942,3 +2200,8 @@ }); | ||
addMenuPopupEl(state); | ||
runFocusOnActive(state); | ||
runFocusOnActive(state); // if (!state.menuBtnEls) { | ||
// state.enableLastFocusSentinel = !!state.mount; | ||
// globalState.addedDocumentClick = true; | ||
// document.addEventListener("click", onDocumentClick, { once: true }); | ||
// } | ||
addGlobalEvents(closeWhenScrolling); | ||
@@ -1964,2 +2227,4 @@ addDismissStack({ | ||
focusSentinelBeforeEl: state.focusSentinelBeforeEl, | ||
focusSentinelAfterEl: state.focusSentinelAfterEl, | ||
ignoreMenuPopupWhenTabbing, | ||
upperStackRemovedByFocusOut: false, | ||
@@ -1998,3 +2263,3 @@ detectIfMenuButtonObscured: false, | ||
removeLocalEvents(state, { | ||
onCleanup: true | ||
isCleanup: true | ||
}); | ||
@@ -2152,3 +2417,3 @@ removeMenuPopupEl(state); | ||
export { Dismiss as default }; | ||
export { Dismiss as default, getNextTabbableElement }; | ||
//# sourceMappingURL=index.js.map |
import { untrack, createComputed, createSignal, children, } from "solid-js"; | ||
import { camelize, queryElement } from "../utils"; | ||
import { camelize } from "../utils/camelize"; | ||
import { queryElement } from "../utils/queryElement"; | ||
export const Transition = (props) => { | ||
@@ -4,0 +5,0 @@ let el; |
import { dismissStack } from "./dismissStack"; | ||
import { getMenuButton, markFocusedMenuButton } from "../local/menuButton"; | ||
import { checkThenClose, queryElement } from "../utils"; | ||
import { getNextTabbableElement } from "../utils/tabbing"; | ||
import { checkThenClose } from "../utils/checkThenClose"; | ||
import { queryElement } from "../utils/queryElement"; | ||
let scrollEventAddedViaTouch = false; | ||
@@ -18,2 +19,3 @@ let scrollEventAdded = false; | ||
focusedMenuBtns: new Set(), | ||
cursorKeysPrevEl: null, | ||
}; | ||
@@ -23,5 +25,6 @@ export const onDocumentClick = (e) => { | ||
checkThenClose(dismissStack, (item) => { | ||
const menuButton = getMenuButton(item.menuBtnEls); | ||
if (item.overlay || | ||
item.overlayElement || | ||
getMenuButton(item.menuBtnEls).contains(target) || | ||
(menuButton && menuButton.contains(target)) || | ||
item.containerEl.contains(target)) | ||
@@ -88,4 +91,19 @@ return; | ||
export const onKeyDown = (e) => { | ||
const { focusedMenuBtn, setOpen, menuBtnEls, cursorKeys, closeWhenEscapeKeyIsPressed, focusElementOnClose, timeouts, } = dismissStack[dismissStack.length - 1]; | ||
const { focusedMenuBtn, setOpen, menuBtnEls, cursorKeys, closeWhenEscapeKeyIsPressed, focusElementOnClose, timeouts, ignoreMenuPopupWhenTabbing, focusSentinelAfterEl, focusSentinelBeforeEl, } = dismissStack[dismissStack.length - 1]; | ||
if (e.key === "Tab") { | ||
if (ignoreMenuPopupWhenTabbing) { | ||
e.preventDefault(); | ||
const shiftKey = e.shiftKey; | ||
// TODO: work with stacks? | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
const el = getNextTabbableElement({ | ||
from: shiftKey ? focusSentinelBeforeEl : focusSentinelAfterEl, | ||
direction: shiftKey ? "backwards" : "forwards", | ||
ignoreElement: menuBtnEl ? [menuBtnEl] : [], | ||
}); | ||
if (el) { | ||
el.focus(); | ||
} | ||
return; | ||
} | ||
timestampOfTabkey = e.timeStamp; | ||
@@ -159,2 +177,3 @@ } | ||
globalState.addedDocumentClick = false; | ||
globalState.cursorKeysPrevEl = null; | ||
// globalState.menuBtnEl = null; | ||
@@ -192,5 +211,5 @@ window.clearTimeout(globalState.documentClickTimeout); | ||
return; | ||
const { menuBtnEls, menuPopupEl, containerEl, focusSentinelBeforeEl } = dismissStack[dismissStack.length - 1]; | ||
const { menuBtnEls, menuPopupEl, containerEl, focusSentinelBeforeEl, focusSentinelAfterEl, cursorKeys, } = dismissStack[dismissStack.length - 1]; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
let activeElement = document.activeElement; | ||
let activeElement = globalState.cursorKeysPrevEl || document.activeElement; | ||
let direction; | ||
@@ -206,10 +225,35 @@ if (e.key === "ArrowDown") { | ||
activeElement === containerEl) { | ||
direction = "forwards"; | ||
activeElement = focusSentinelBeforeEl; | ||
if (e.key === "ArrowUp") { | ||
direction = "backwards"; | ||
activeElement = focusSentinelAfterEl; | ||
} | ||
else { | ||
direction = "forwards"; | ||
activeElement = focusSentinelBeforeEl; | ||
} | ||
} | ||
const el = getNextTabbableElement({ | ||
const isCursorKeysArgObj = typeof cursorKeys === "object"; | ||
const willWrap = isCursorKeysArgObj && cursorKeys.wrap; | ||
let el = getNextTabbableElement({ | ||
from: activeElement, | ||
direction, | ||
stopAtElement: menuPopupEl, | ||
stopAtRootElement: menuPopupEl, | ||
}); | ||
if (!el && willWrap) { | ||
const from = e.key === "ArrowDown" ? focusSentinelBeforeEl : focusSentinelAfterEl; | ||
direction = e.key === "ArrowDown" ? "forwards" : "backwards"; | ||
el = getNextTabbableElement({ | ||
from, | ||
direction, | ||
stopAtRootElement: containerEl, | ||
}); | ||
} | ||
if (isCursorKeysArgObj && cursorKeys.onKeyDown) { | ||
cursorKeys.onKeyDown({ | ||
currentEl: el, | ||
prevEl: globalState.cursorKeysPrevEl, | ||
}); | ||
globalState.cursorKeysPrevEl = el; | ||
return; | ||
} | ||
if (el) { | ||
@@ -216,0 +260,0 @@ el.focus(); |
import "./browserInfo"; | ||
import { isServer } from "solid-js/web"; | ||
import { untrack, createEffect, onCleanup, on, createUniqueId, createMemo, createComputed, } from "solid-js"; | ||
import { hasDisplayNone, queryElement } from "./utils"; | ||
import { dismissStack, addDismissStack, removeDismissStack, } from "./global/dismissStack"; | ||
@@ -9,3 +8,3 @@ import { addGlobalEvents, globalState, onDocumentClick, removeGlobalEvents, } from "./global/globalEvents"; | ||
import { addMenuPopupEl, removeMenuPopupEl } from "./local/menuPopup"; | ||
import { getMenuButton, markFocusedMenuButton, onBlurMenuButton, onClickMenuButton, onClickOutsideMenuButton, onFocusMenuButton, onKeydownMenuButton, onMouseDownMenuButton, removeMenuButtonEvents, } from "./local/menuButton"; | ||
import { addMenuButtonEventsAndAttr, getMenuButton, markFocusedMenuButton, onBlurMenuButton, onClickMenuButton, onClickOutsideMenuButton, onFocusMenuButton, onKeydownMenuButton, onMouseDownMenuButton, removeMenuButtonEvents, } from "./local/menuButton"; | ||
import CreatePortal from "./components/CreatePortal"; | ||
@@ -17,2 +16,4 @@ import { Transition } from "./components/Transition"; | ||
import { activateLastFocusSentinel, onFocusSentinel, } from "./local/focusSentinel"; | ||
import { queryElement } from "./utils/queryElement"; | ||
import { getNextTabbableElement } from "./utils/tabbing"; | ||
/** | ||
@@ -24,6 +25,5 @@ * | ||
const modal = props.modal || false; | ||
// @ts-check | ||
const { id, menuButton, menuPopup, focusElementOnClose, focusElementOnOpen, cursorKeys = false, closeWhenMenuButtonIsTabbed = false, closeWhenMenuButtonIsClicked = true, closeWhenScrolling = false, closeWhenDocumentBlurs = false, closeWhenOverlayClicked = true, closeWhenEscapeKeyIsPressed = true, overlay = modal, overlayElement = modal, trapFocus = modal, removeScrollbar = modal, enableLastFocusSentinel = false, mount = modal ? "body" : undefined, | ||
// stopComponentEventPropagation = false, | ||
show = false, onToggleScrollbar, onOpen, } = props; | ||
show = false, onToggleScrollbar, onOpen, deadMenuButton, ignoreMenuPopupWhenTabbing, } = props; | ||
const state = { | ||
@@ -40,3 +40,5 @@ mount, | ||
focusElementOnClose, | ||
deadMenuButton, | ||
focusElementOnOpen, | ||
ignoreMenuPopupWhenTabbing, | ||
// @ts-ignore | ||
@@ -348,41 +350,9 @@ id, | ||
: props.menuButton, (menuButton) => { | ||
if (Array.isArray(menuButton) && !menuButton.length) | ||
return; | ||
const { focusedMenuBtn } = state; | ||
const menuBtnEls = queryElement(state, { | ||
inputElement: menuButton, | ||
type: "menuButton", | ||
addMenuButtonEventsAndAttr({ | ||
state, | ||
menuButton, | ||
open: props.open, | ||
}); | ||
if (!menuBtnEls) | ||
return; | ||
state.menuBtnEls = Array.isArray(menuBtnEls) | ||
? menuBtnEls | ||
: [menuBtnEls]; | ||
state.menuBtnEls.forEach((menuBtnEl, _, self) => { | ||
if (focusedMenuBtn.el && | ||
focusedMenuBtn.el !== menuBtnEl && | ||
(self.length > 1 ? !hasDisplayNone(menuBtnEl) : true)) { | ||
focusedMenuBtn.el = menuBtnEl; | ||
menuBtnEl.focus({ preventScroll: true }); | ||
menuBtnEl.addEventListener("keydown", state.onKeydownMenuButtonRef); | ||
} | ||
menuBtnEl.setAttribute("type", "button"); | ||
menuBtnEl.addEventListener("click", state.onClickMenuButtonRef); | ||
menuBtnEl.addEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
if (props.open() && | ||
(!state.focusElementOnOpen || | ||
state.focusElementOnOpen === "menuButton" || | ||
state.focusElementOnOpen === state.menuBtnEls) && | ||
!hasDisplayNone(menuBtnEl)) { | ||
menuBtnEl.addEventListener("blur", state.onBlurMenuButtonRef, { | ||
once: true, | ||
}); | ||
} | ||
}); | ||
const item = dismissStack.find((item) => item.uniqueId === state.uniqueId); | ||
if (item) { | ||
item.menuBtnEls = state.menuBtnEls; | ||
} | ||
onCleanup(() => { | ||
if (!state) | ||
if (!state || isServer) | ||
return; | ||
@@ -445,2 +415,7 @@ removeMenuButtonEvents(state, true); | ||
runFocusOnActive(state); | ||
// if (!state.menuBtnEls) { | ||
// state.enableLastFocusSentinel = !!state.mount; | ||
// globalState.addedDocumentClick = true; | ||
// document.addEventListener("click", onDocumentClick, { once: true }); | ||
// } | ||
addGlobalEvents(closeWhenScrolling); | ||
@@ -466,2 +441,4 @@ addDismissStack({ | ||
focusSentinelBeforeEl: state.focusSentinelBeforeEl, | ||
focusSentinelAfterEl: state.focusSentinelAfterEl, | ||
ignoreMenuPopupWhenTabbing, | ||
upperStackRemovedByFocusOut: false, | ||
@@ -492,3 +469,3 @@ detectIfMenuButtonObscured: false, | ||
return; | ||
removeLocalEvents(state, { onCleanup: true }); | ||
removeLocalEvents(state, { isCleanup: true }); | ||
removeMenuPopupEl(state); | ||
@@ -545,2 +522,3 @@ removeOutsideFocusEvents(state); | ||
}; | ||
export { getNextTabbableElement }; | ||
export default Dismiss; |
import { dismissStack } from "../global/dismissStack"; | ||
import { globalState, onDocumentClick } from "../global/globalEvents"; | ||
import { queryElement } from "../utils"; | ||
import { queryElement } from "../utils/queryElement"; | ||
// Safari, if relatedTarget is not contained within focusout, it will be null | ||
export const onFocusOutContainer = (state, e) => { | ||
const { overlay, overlayElement, open, mount, setOpen, timeouts, stopComponentEventPropagation, focusedMenuBtn, } = state; | ||
const { overlay, overlayElement, open, mount, setOpen, timeouts, stopComponentEventPropagation, focusedMenuBtn, menuButton, deadMenuButton, } = state; | ||
const relatedTarget = e.relatedTarget; | ||
@@ -33,2 +33,5 @@ if (overlay) | ||
} | ||
if (relatedTarget === menuButton && deadMenuButton) { | ||
return; | ||
} | ||
timeouts.containerFocusTimeoutId = window.setTimeout(() => { | ||
@@ -47,4 +50,5 @@ globalState.closedByEvents = true; | ||
const { focusElementOnOpen, focusedMenuBtn } = state; | ||
if (focusElementOnOpen == null) | ||
if (focusElementOnOpen == null) { | ||
return; | ||
} | ||
const el = queryElement(state, { | ||
@@ -51,0 +55,0 @@ inputElement: focusElementOnOpen, |
import { dismissStack } from "../global/dismissStack"; | ||
import { globalState } from "../global/globalEvents"; | ||
import { checkThenClose, matchByFirstChild, queryElement } from "../utils"; | ||
import { checkThenClose } from "../utils/checkThenClose"; | ||
import { matchByFirstChild } from "../utils/matchByFirstChild"; | ||
import { queryElement } from "../utils/queryElement"; | ||
import { getNextTabbableElement } from "../utils/tabbing"; | ||
@@ -8,4 +10,10 @@ import { getMenuButton } from "./menuButton"; | ||
const { enableLastFocusSentinel, menuBtnEls, containerEl, focusSentinelAfterEl, } = state; | ||
if (enableLastFocusSentinel) | ||
// Before | ||
// if (enableLastFocusSentinel) return; | ||
if (enableLastFocusSentinel) { | ||
focusSentinelAfterEl.setAttribute("tabindex", "0"); | ||
return; | ||
} | ||
if (!menuBtnEls) | ||
return; | ||
const menuBtnEl = getMenuButton(menuBtnEls); | ||
@@ -57,6 +65,8 @@ const menuBtnSibling = menuBtnEl.nextElementSibling; | ||
return; | ||
if (relatedTarget === containerEl || relatedTarget === menuBtnEl) { | ||
if (menuBtnEl && | ||
(relatedTarget === containerEl || relatedTarget === menuBtnEl)) { | ||
const el = getNextTabbableElement({ | ||
from: focusSentinelBeforeEl, | ||
stopAtElement: containerEl, | ||
direction: "forwards", | ||
stopAtRootElement: containerEl, | ||
}); | ||
@@ -71,3 +81,3 @@ el.focus(); | ||
direction: "backwards", | ||
stopAtElement: containerEl, | ||
stopAtRootElement: containerEl, | ||
}); | ||
@@ -88,3 +98,13 @@ el.focus(); | ||
}) || menuBtnEl; | ||
// conditional and early return just for no button feature | ||
if (!state.menuBtnEls) { | ||
// this wasn't here before | ||
el.focus(); | ||
return; | ||
} | ||
// this wasn't wrapped with mount condition when it should be | ||
// if (mount) { | ||
runIfMounted(el, true); | ||
// } | ||
// so | ||
return; | ||
@@ -95,3 +115,3 @@ } | ||
from: focusSentinelBeforeEl, | ||
stopAtElement: containerEl, | ||
stopAtRootElement: containerEl, | ||
}); | ||
@@ -98,0 +118,0 @@ el.focus(); |
import { removeMenuButtonEvents } from "./menuButton"; | ||
export const removeLocalEvents = (state, { onCleanup = false } = {}) => { | ||
export const removeLocalEvents = (state, { isCleanup = false } = {}) => { | ||
document.removeEventListener("click", state.onClickDocumentRef); | ||
removeMenuButtonEvents(state, onCleanup); | ||
removeMenuButtonEvents(state, isCleanup); | ||
}; |
import { dismissStack } from "../global/dismissStack"; | ||
import { globalState, onDocumentClick } from "../global/globalEvents"; | ||
import { removeOutsideFocusEvents } from "./outside"; | ||
import { checkThenClose, hasDisplayNone } from "../utils"; | ||
import { getNextTabbableElement } from "../utils/tabbing"; | ||
import { checkThenClose } from "../utils/checkThenClose"; | ||
import { hasDisplayNone } from "../utils/hasDisplayNone"; | ||
import { queryElement } from "../utils/queryElement"; | ||
let mousedownFired = false; | ||
export const onClickMenuButton = (state, e) => { | ||
const { timeouts, closeWhenMenuButtonIsClicked, focusedMenuBtn, onClickOutsideMenuButtonRef: onClickOutsideRef, setOpen, open, } = state; | ||
const { timeouts, closeWhenMenuButtonIsClicked, focusedMenuBtn, onClickOutsideMenuButtonRef: onClickOutsideRef, setOpen, open, deadMenuButton, } = state; | ||
const menuBtnEl = e.currentTarget; | ||
@@ -16,2 +18,9 @@ globalState.focusedMenuBtns.forEach((item) => (item.el = null)); | ||
// globalState.menuBtnEls.clear(); | ||
if (deadMenuButton) { | ||
globalState.addedDocumentClick = true; | ||
setTimeout(() => { | ||
document.addEventListener("click", onDocumentClick, { once: true }); | ||
}); | ||
return; | ||
} | ||
state.menuBtnKeyupTabFired = false; | ||
@@ -111,3 +120,3 @@ if (mousedownFired && !open()) { | ||
export const onKeydownMenuButton = (state, e) => { | ||
const { containerEl, setOpen, open, onKeydownMenuButtonRef, onBlurMenuButtonRef, mount, focusSentinelBeforeEl, focusSentinelAfterEl, } = state; | ||
const { containerEl, setOpen, open, onKeydownMenuButtonRef, onBlurMenuButtonRef, mount, focusSentinelBeforeEl, focusSentinelAfterEl, ignoreMenuPopupWhenTabbing, } = state; | ||
const menuBtnEl = e.currentTarget; | ||
@@ -144,5 +153,23 @@ if (e.key !== "Tab") | ||
e.preventDefault(); | ||
if (ignoreMenuPopupWhenTabbing) { | ||
const el = getNextTabbableElement({ | ||
from: menuBtnEl, | ||
direction: "forwards", | ||
ignoreElement: [ | ||
containerEl, | ||
focusSentinelBeforeEl, | ||
focusSentinelAfterEl, | ||
], | ||
}); | ||
if (el) { | ||
el.focus(); | ||
} | ||
setOpen(false); | ||
menuBtnEl.removeEventListener("keydown", onKeydownMenuButtonRef); | ||
menuBtnEl.removeEventListener("blur", onBlurMenuButtonRef); | ||
return; | ||
} | ||
let el = getNextTabbableElement({ | ||
from: focusSentinelBeforeEl, | ||
stopAtElement: containerEl, | ||
stopAtRootElement: containerEl, | ||
}); | ||
@@ -168,3 +195,13 @@ if (el) { | ||
export const onFocusMenuButton = (state) => { | ||
const { closeWhenMenuButtonIsTabbed, timeouts } = state; | ||
const { closeWhenMenuButtonIsTabbed, timeouts, deadMenuButton, menuBtnEls } = state; | ||
if (deadMenuButton) { | ||
const menuBtn = getMenuButton(menuBtnEls); | ||
menuBtn.addEventListener("blur", state.onBlurMenuButtonRef); | ||
menuBtn.addEventListener("keydown", state.onKeydownMenuButtonRef); | ||
globalState.addedDocumentClick = true; | ||
setTimeout(() => { | ||
document.addEventListener("click", onDocumentClick, { once: true }); | ||
}); | ||
return; | ||
} | ||
if (!closeWhenMenuButtonIsTabbed) { | ||
@@ -199,10 +236,57 @@ clearTimeout(timeouts.containerFocusTimeoutId); | ||
}; | ||
export const removeMenuButtonEvents = (state, onCleanup) => { | ||
export const addMenuButtonEventsAndAttr = ({ state, menuButton, open, }) => { | ||
if (Array.isArray(menuButton) && !menuButton.length) | ||
return; | ||
const { focusedMenuBtn } = state; | ||
const menuBtnEls = queryElement(state, { | ||
inputElement: menuButton, | ||
type: "menuButton", | ||
}); | ||
if (!menuBtnEls) { | ||
return; | ||
} | ||
state.menuBtnEls = Array.isArray(menuBtnEls) ? menuBtnEls : [menuBtnEls]; | ||
const item = dismissStack.find((item) => item.uniqueId === state.uniqueId); | ||
if (item) { | ||
item.menuBtnEls = state.menuBtnEls; | ||
} | ||
if (state.deadMenuButton) { | ||
state.menuBtnEls.forEach((menuBtnEl) => { | ||
menuBtnEl.addEventListener("click", state.onClickMenuButtonRef); | ||
menuBtnEl.addEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
menuBtnEl.addEventListener("focus", state.onFocusMenuButtonRef); | ||
}); | ||
return; | ||
} | ||
state.menuBtnEls.forEach((menuBtnEl, _, self) => { | ||
if (focusedMenuBtn.el && | ||
focusedMenuBtn.el !== menuBtnEl && | ||
(self.length > 1 ? !hasDisplayNone(menuBtnEl) : true)) { | ||
focusedMenuBtn.el = menuBtnEl; | ||
menuBtnEl.focus({ preventScroll: true }); | ||
menuBtnEl.addEventListener("keydown", state.onKeydownMenuButtonRef); | ||
} | ||
menuBtnEl.setAttribute("type", "button"); | ||
menuBtnEl.addEventListener("click", state.onClickMenuButtonRef); | ||
menuBtnEl.addEventListener("mousedown", state.onMouseDownMenuButtonRef); | ||
if (open() && | ||
(!state.focusElementOnOpen || | ||
state.focusElementOnOpen === "menuButton" || | ||
state.focusElementOnOpen === state.menuBtnEls) && | ||
!hasDisplayNone(menuBtnEl)) { | ||
menuBtnEl.addEventListener("blur", state.onBlurMenuButtonRef, { | ||
once: true, | ||
}); | ||
} | ||
}); | ||
}; | ||
export const removeMenuButtonEvents = (state, isCleanup) => { | ||
if (!state || !state.menuBtnEls) | ||
return; | ||
state.menuBtnEls.forEach((menuBtnEl) => { | ||
menuBtnEl.removeEventListener("focus", state.onFocusMenuButtonRef); | ||
// menuBtnEl.removeEventListener("keydown", state.onKeydownMenuButtonRef); | ||
if (!state.deadMenuButton) { | ||
menuBtnEl.removeEventListener("focus", state.onFocusMenuButtonRef); | ||
} | ||
menuBtnEl.removeEventListener("blur", state.onBlurMenuButtonRef); | ||
if (onCleanup) { | ||
if (isCleanup) { | ||
menuBtnEl.removeEventListener("click", state.onClickMenuButtonRef); | ||
@@ -209,0 +293,0 @@ menuBtnEl.removeEventListener("mousedown", state.onMouseDownMenuButtonRef); |
@@ -1,2 +0,2 @@ | ||
import { queryElement } from "../utils"; | ||
import { queryElement } from "../utils/queryElement"; | ||
export const addMenuPopupEl = (state) => { | ||
@@ -3,0 +3,0 @@ const { menuPopup } = state; |
import { dismissStack } from "../global/dismissStack"; | ||
import { globalState } from "../global/globalEvents"; | ||
import { checkThenClose, queryElement } from "../utils"; | ||
import { checkThenClose } from "../utils/checkThenClose"; | ||
import { queryElement } from "../utils/queryElement"; | ||
import { getMenuButton } from "./menuButton"; | ||
@@ -5,0 +6,0 @@ export const onClickOverlay = (state) => { |
@@ -12,4 +12,27 @@ const _tabbableSelectors = [ | ||
].reduce((a, c, idx) => `${a}${idx ? "," : ""}${c}:not([tabindex="-1"])`, ""); | ||
export const getNextTabbableElement = ({ from = document.activeElement, stopAtElement, ignoreElement = [], allowSelectors, direction = "forwards", }) => { | ||
let willWrap = false; | ||
let originalFrom = null; | ||
export const getNextTabbableElement = ({ from: _from, stopAtRootElement: stopAtRootElement, ignoreElement = [], allowSelectors, direction = "forwards", wrap, }) => { | ||
let fromResult; | ||
let _isFromElIframe = false; | ||
if (!(_from instanceof Element)) { | ||
if (_from === "activeElement") { | ||
const activeElement = document.activeElement; | ||
_isFromElIframe = isIframe(activeElement); | ||
fromResult = getActiveElement(activeElement); | ||
} | ||
if (typeof _from === "object") { | ||
if (_from.getActiveElement) { | ||
fromResult = getActiveElement(_from.el); | ||
} | ||
_isFromElIframe = _from.isIframe; | ||
} | ||
} | ||
else { | ||
_isFromElIframe = isIframe(_from); | ||
fromResult = _from; | ||
} | ||
const from = fromResult; | ||
const parent = from.parentElement; | ||
const isFromElIframe = _isFromElIframe; | ||
const visitedElement = from; | ||
@@ -19,30 +42,2 @@ const tabbableSelectors = _tabbableSelectors + (allowSelectors ? "," + allowSelectors.join(",") : ""); | ||
return null; | ||
const isHidden = (el, contentWindow = window) => { | ||
const checkByStyle = (style) => style.display === "none" || style.visibility === "hidden"; | ||
if ((el.style && checkByStyle(el.style)) || el.hidden) | ||
return true; | ||
const style = contentWindow.getComputedStyle(el); | ||
if (!style || checkByStyle(style)) | ||
return true; | ||
return false; | ||
}; | ||
const checkHiddenAncestors = (target, parent, contentWindow) => { | ||
const ancestors = []; | ||
let node = target; | ||
if (isHidden(node)) | ||
return true; | ||
while (true) { | ||
node = node.parentElement; | ||
if (!node || node === parent) { | ||
break; | ||
} | ||
ancestors.push(node); | ||
} | ||
for (const node of ancestors) { | ||
if (isHidden(node, contentWindow)) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
}; | ||
const checkChildren = (children, parent, reverse, contentWindow) => { | ||
@@ -57,4 +52,6 @@ const length = children.length; | ||
continue; | ||
if (originalFrom === child) | ||
continue; | ||
if (!checkHiddenAncestors(child, parent, contentWindow)) { | ||
if (child.tagName === "IFRAME") { | ||
if (isIframe(child)) { | ||
const iframeChild = queryIframe(child, reverse); | ||
@@ -73,4 +70,6 @@ if (iframeChild) | ||
continue; | ||
if (originalFrom === child) | ||
continue; | ||
if (!checkHiddenAncestors(child, parent, contentWindow)) { | ||
if (child.tagName === "IFRAME") { | ||
if (isIframe(child)) { | ||
const iframeChild = queryIframe(child); | ||
@@ -85,19 +84,14 @@ if (iframeChild) | ||
}; | ||
const getIframeWindow = (iframe) => { | ||
try { | ||
return iframe.contentWindow; | ||
} | ||
catch (e) { | ||
return null; | ||
} | ||
}; | ||
const queryIframe = (el, inverseQuery) => { | ||
if (!el) | ||
return null; | ||
if (el.tagName !== "IFRAME") | ||
if (!isIframe(el)) | ||
return el; | ||
const iframeWindow = getIframeWindow(el); | ||
const iframeDocument = iframeWindow.document; | ||
// here iframe will get focused whether it has tab index or not, so checking tabindex conditional a couple lines down is redundant | ||
if (!iframeWindow) | ||
return el; | ||
const iframeDocument = iframeWindow.document; | ||
// conditional used to be here | ||
// if (!iframeWindow) return el as HTMLElement; | ||
const tabindex = el.getAttribute("tabindex"); | ||
@@ -114,2 +108,5 @@ if (tabindex) | ||
const childrenCount = children.length; | ||
if (willWrap) { | ||
hasPassedVisitedElement = true; | ||
} | ||
if (direction === "forwards") { | ||
@@ -136,3 +133,3 @@ for (let i = 0; i < childrenCount; i++) { | ||
} | ||
if (child === stopAtElement) { | ||
if (child === stopAtRootElement) { | ||
return null; | ||
@@ -166,3 +163,3 @@ } | ||
} | ||
if (child === stopAtElement) { | ||
if (child === stopAtRootElement) { | ||
return null; | ||
@@ -178,8 +175,84 @@ } | ||
parent = parent.parentElement; | ||
if (!parent) | ||
if (!parent && isFromElIframe) { | ||
// TODO: only get's top level iframe, should get correct iframe | ||
const iframe = document.activeElement; | ||
if (iframe && isIframe(iframe)) { | ||
visitedElement = iframe; | ||
parent = iframe.parentElement; | ||
} | ||
} | ||
if (!parent) { | ||
return null; | ||
} | ||
return traverseNextSiblingsThenUp(parent, visitedElement); | ||
}; | ||
const result = traverseNextSiblingsThenUp(parent, visitedElement); | ||
let result = traverseNextSiblingsThenUp(parent, visitedElement); | ||
if (!result && wrap && stopAtRootElement) { | ||
// direction = direction === "forwards" ? "backwards" : "forwards"; | ||
willWrap = true; | ||
originalFrom = from; | ||
result = getNextTabbableElement({ | ||
from: stopAtRootElement, | ||
allowSelectors, | ||
direction, | ||
ignoreElement, | ||
// stopAtElement, | ||
wrap: false, | ||
}); | ||
} | ||
willWrap = false; | ||
originalFrom = null; | ||
return result; | ||
}; | ||
const getIframeWindow = (iframe) => { | ||
try { | ||
return iframe.contentWindow; | ||
} | ||
catch (e) { | ||
return null; | ||
} | ||
}; | ||
const getIframeDocument = (iframe) => { | ||
const iframeWindow = getIframeWindow(iframe); | ||
if (!iframeWindow) | ||
return null; | ||
return iframeWindow.document; | ||
}; | ||
const getActiveElement = (el) => { | ||
// TODO: only goes one depth, should go infinitly | ||
if (!isIframe(el)) | ||
return el; | ||
const iframeDocument = getIframeDocument(el); | ||
if (!iframeDocument) | ||
return el; | ||
return iframeDocument.activeElement || el; | ||
}; | ||
const isHidden = (el, contentWindow = window) => { | ||
const checkByStyle = (style) => style.display === "none" || style.visibility === "hidden"; | ||
if ((el.style && checkByStyle(el.style)) || el.hidden) | ||
return true; | ||
const style = contentWindow.getComputedStyle(el); | ||
if (!style || checkByStyle(style)) | ||
return true; | ||
return false; | ||
}; | ||
const checkHiddenAncestors = (target, parent, contentWindow) => { | ||
const ancestors = []; | ||
let node = target; | ||
if (isHidden(node)) | ||
return true; | ||
while (true) { | ||
node = node.parentElement; | ||
if (!node || node === parent) { | ||
break; | ||
} | ||
ancestors.push(node); | ||
} | ||
for (const node of ancestors) { | ||
if (isHidden(node, contentWindow)) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
}; | ||
const isIframe = (el) => el.tagName === "IFRAME"; |
import { TDismiss } from ".."; | ||
import { TLocalState } from "../local/localState"; | ||
export declare type TDismissStack = Pick<TDismiss, "focusElementOnClose" | "overlayElement" | "open" | "setOpen"> & Pick<TLocalState, "timeouts" | "uniqueId" | "cursorKeys" | "closeWhenDocumentBlurs" | "closeWhenMenuButtonIsTabbed" | "closeWhenEscapeKeyIsPressed" | "upperStackRemovedByFocusOut" | "menuPopupEl" | "overlayEl" | "overlay" | "focusSentinelBeforeEl"> & { | ||
export declare type TDismissStack = Pick<TDismiss, "focusElementOnClose" | "overlayElement" | "open" | "setOpen"> & Pick<TLocalState, "timeouts" | "uniqueId" | "cursorKeys" | "closeWhenDocumentBlurs" | "closeWhenMenuButtonIsTabbed" | "closeWhenEscapeKeyIsPressed" | "upperStackRemovedByFocusOut" | "menuPopupEl" | "overlayEl" | "overlay" | "focusSentinelBeforeEl" | "focusSentinelAfterEl" | "ignoreMenuPopupWhenTabbing"> & { | ||
id: string; | ||
@@ -5,0 +5,0 @@ menuBtnEls: HTMLElement[]; |
@@ -11,2 +11,3 @@ export declare const globalState: { | ||
closedByEvents: boolean; | ||
cursorKeysPrevEl: HTMLElement | null; | ||
}; | ||
@@ -13,0 +14,0 @@ export declare const onDocumentClick: (e: Event) => void; |
@@ -5,2 +5,19 @@ import "./browserInfo"; | ||
import { DismissAnimation } from "./components/Transition"; | ||
import { getNextTabbableElement } from "./utils/tabbing"; | ||
/** | ||
* ### stack popup bug | ||
* | ||
* 1. open 4 stacks | ||
* 2. click 3rd stack, which removes 1 stack (topmost), leaving 3 stacks left | ||
* 3. click outside of all stacks | ||
* | ||
* ## Expected | ||
* | ||
* To close all stacks (remaining 2 stacks), due to none of the stacks containing new focused element, cuz you clicked outside of all stacks | ||
* | ||
* ## Actual Result | ||
* | ||
* Only the 3rd stack (topmost) gets removed. All stacks should have been removed. Clicking ou and nothing happens. | ||
* | ||
*/ | ||
export declare type TDismiss = { | ||
@@ -41,3 +58,14 @@ /** | ||
*/ | ||
cursorKeys?: boolean; | ||
cursorKeys?: boolean | { | ||
/** | ||
* When focused on the last dropdown item, when continueing in the same direction, the first item will be focused. | ||
* | ||
* @defaultValue `false` | ||
*/ | ||
wrap: boolean; | ||
onKeyDown?: (props: { | ||
currentEl: HTMLElement | null; | ||
prevEl: HTMLElement | null; | ||
}) => void; | ||
}; | ||
/** | ||
@@ -56,3 +84,3 @@ * | ||
* | ||
* @defaultValue focus remains on `"menuButton"` | ||
* @defaultValue focus remains on `"menuButton"`. But if there's no menu button, focus remains on document's activeElement. | ||
*/ | ||
@@ -75,5 +103,55 @@ focusElementOnOpen?: "menuPopup" | "firstChild" | JSX.Element | (() => JSX.Element); | ||
*/ | ||
focusElementOnClose?: "menuButton" | JSX.Element | FocusElementOnCloseOptions; | ||
focusElementOnClose?: "menuButton" | JSX.Element | { | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via tabbing backwards ie "Shift + Tab". | ||
* | ||
* @defaultValue `"menuButton"` | ||
* | ||
*/ | ||
tabBackwards?: "menuButton" | JSX.Element; | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via tabbing forwards ie "Tab". | ||
* | ||
* @remarks | ||
* | ||
* If popup is mounted elsewhere in the DOM, when tabbing outside, this library is able to grab the correct next tabbable element after menuButton, except for tabbable elements inside iframe with cross domain. | ||
* | ||
* @defaultValue next tabbable element after menuButton; | ||
*/ | ||
tabForwards?: "menuButton" | JSX.Element; | ||
/** | ||
* focus on element when exiting menuPopup via click outside popup. | ||
* | ||
* If mounted overlay present, and popup closes via click, then menuButton will be focused. | ||
* | ||
* @remarks | ||
* | ||
* When clicking, user-agent determines which element recieves focus. | ||
*/ | ||
click?: "menuButton" | JSX.Element; | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via "Escape" key | ||
* | ||
* @defaultValue `"menuButton"` | ||
*/ | ||
escapeKey?: "menuButton" | JSX.Element; | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via scrolling, from scrollable container that contains menuButton | ||
* | ||
* @dafaultValue `"menuButton"` | ||
*/ | ||
scrolling?: "menuButton" | JSX.Element; | ||
}; | ||
/** | ||
* When `true`, clicking or focusing on menuButton doesn't toggle menuPopup. However the menuButton is still used as reference from `focusElementOnClose` | ||
* | ||
* @defaultValue `false` | ||
*/ | ||
deadMenuButton?: boolean; | ||
/** | ||
* | ||
* When `true`, after focusing within menuPopup, if focused back to menu button via keyboard (Tab key), the menuPopup will close. | ||
@@ -202,47 +280,8 @@ * | ||
show?: boolean; | ||
}; | ||
declare type FocusElementOnCloseOptions = { | ||
/** | ||
* If `true`, when pressing Tab key, all tabbable elements in menuPopup are ignored, and the next focusable element is based on `focusElementOnClose`. | ||
* | ||
* focus on element when exiting menuPopup via tabbing backwards ie "Shift + Tab". | ||
* | ||
* @defaultValue `"menuButton"` | ||
* | ||
* @defaultValue `false` | ||
*/ | ||
tabBackwards?: "menuButton" | JSX.Element; | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via tabbing forwards ie "Tab". | ||
* | ||
* @remarks | ||
* | ||
* If popup is mounted elsewhere in the DOM, when tabbing outside, this library is able to grab the correct next tabbable element after menuButton, except for tabbable elements inside iframe with cross domain. | ||
* | ||
* @defaultValue next tabbable element after menuButton; | ||
*/ | ||
tabForwards?: "menuButton" | JSX.Element; | ||
/** | ||
* focus on element when exiting menuPopup via click outside popup. | ||
* | ||
* If mounted overlay present, and popup closes via click, then menuButton will be focused. | ||
* | ||
* @remarks | ||
* | ||
* When clicking, user-agent determines which element recieves focus. | ||
*/ | ||
click?: "menuButton" | JSX.Element; | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via "Escape" key | ||
* | ||
* @defaultValue `"menuButton"` | ||
*/ | ||
escapeKey?: "menuButton" | JSX.Element; | ||
/** | ||
* | ||
* focus on element when exiting menuPopup via scrolling, from scrollable container that contains menuButton | ||
* | ||
* @dafaultValue `"menuButton"` | ||
*/ | ||
scrolling?: "menuButton" | JSX.Element; | ||
ignoreMenuPopupWhenTabbing?: boolean; | ||
}; | ||
@@ -259,2 +298,3 @@ export declare type OnOpenHandler = (open: boolean, props: { | ||
declare const Dismiss: ParentComponent<TDismiss>; | ||
export { getNextTabbableElement }; | ||
export default Dismiss; |
import { TLocalState } from "./localState"; | ||
export declare const removeLocalEvents: (state: TLocalState, { onCleanup }?: { | ||
onCleanup?: boolean; | ||
export declare const removeLocalEvents: (state: TLocalState, { isCleanup }?: { | ||
isCleanup?: boolean; | ||
}) => void; |
import { TLocalState } from "./localState"; | ||
import { TDismiss } from ".."; | ||
export declare const onClickMenuButton: (state: TLocalState, e: Event) => void; | ||
@@ -12,2 +13,7 @@ export declare const onBlurMenuButton: (state: TLocalState, e: FocusEvent) => void; | ||
} & Pick<TLocalState, "timeouts" | "focusedMenuBtn">) => void; | ||
export declare const removeMenuButtonEvents: (state: TLocalState, onCleanup?: boolean) => void; | ||
export declare const addMenuButtonEventsAndAttr: ({ state, menuButton, open, }: { | ||
state: TLocalState; | ||
menuButton: TDismiss["menuButton"]; | ||
open: () => boolean; | ||
}) => void; | ||
export declare const removeMenuButtonEvents: (state: TLocalState, isCleanup?: boolean) => void; |
@@ -1,7 +0,33 @@ | ||
export declare const getNextTabbableElement: ({ from, stopAtElement, ignoreElement, allowSelectors, direction, }: { | ||
from: Element; | ||
stopAtElement?: HTMLElement; | ||
declare type GetNextTabbableElement = { | ||
/** | ||
* Sets the relative position on getting the next tabbable element | ||
* | ||
* If `"activeElement"`, gets the current active element either from document or iframe context. | ||
* | ||
* If you are passing an iframe element, but intending to use current active element inside that iframe context, then use object argument `{ el: Element; getActiveElement: true }` | ||
*/ | ||
from: Element | "activeElement" | { | ||
el: Element; | ||
getActiveElement: boolean; | ||
isIframe: boolean; | ||
}; | ||
direction?: "forwards" | "backwards"; | ||
stopAtRootElement?: HTMLElement; | ||
/** | ||
* Skips tabbable elements | ||
* | ||
* @defaultValue `undefined` | ||
*/ | ||
ignoreElement?: HTMLElement[]; | ||
allowSelectors?: string[]; | ||
direction?: "forwards" | "backwards"; | ||
}) => HTMLElement; | ||
/** | ||
* To be used with `stopAtRootElement`. | ||
* | ||
* When `from` is the last tabbable item within `stopAtRootElement`, when continueing in the same direction, the first item will be focused within `stopAtRootElement`. | ||
* | ||
* @defaultValue `false` | ||
*/ | ||
wrap?: boolean; | ||
}; | ||
export declare const getNextTabbableElement: ({ from: _from, stopAtRootElement: stopAtRootElement, ignoreElement, allowSelectors, direction, wrap, }: GetNextTabbableElement) => HTMLElement; | ||
export {}; |
{ | ||
"name": "solid-dismiss", | ||
"version": "1.2.3", | ||
"version": "1.3.0", | ||
"homepage": "https://aquaductape.github.io/solid-dismiss/", | ||
@@ -60,4 +60,4 @@ "description": "Handles \"click outside\" behavior for popup menu. Closing is triggered by click/focus outside of popup element or pressing \"Escape\" key.", | ||
"typedoc": "^0.22.4", | ||
"typescript": "^4.6.4" | ||
"typescript": "^4.8.4" | ||
} | ||
} |
@@ -52,2 +52,34 @@ <p> | ||
## Using SSR | ||
On SSR frameworks such as [Astro](https://docs.astro.build/en/guides/integrations-guide/solid-js/) or [solid-start](https://github.com/solidjs/solid-start), you need to include `["solid-dismiss"]` value to the `noExternal` property in the vite config file. | ||
```js | ||
// solid-start vite.config.js | ||
import solid from "solid-start/vite"; | ||
import { defineConfig } from "vite"; | ||
export default defineConfig({ | ||
plugins: [solid()], | ||
ssr: { | ||
noExternal: ["solid-dismiss"], | ||
}, | ||
}); | ||
``` | ||
```js | ||
// astro astro.config.mjs | ||
import { defineConfig } from "astro/config"; | ||
import solidJs from "@astrojs/solid-js"; | ||
export default defineConfig({ | ||
integrations: [solidJs()], | ||
vite: { | ||
ssr: { | ||
noExternal: ["solid-dismiss"], | ||
}, | ||
}, | ||
}); | ||
``` | ||
## Caveat | ||
@@ -54,0 +86,0 @@ |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
330712
45
4483
386